_little-star_

学习的博客

0%

[TOC]

1、计算机网络概念

1、什么是计算机网络?

计算机网络是指将==地理位置不同==的具有独立功能的==多台计算机及其外部设备,通过通信线路连接(有线性、无线)起来==,在网络操作系统,网络管理软件及==网络通信协议==的管理和协调下,实现==资源共享==和==信息传递==的计算机系统。

类比:信件

image-20210815223740558

2、网络编程的目的

  • ==传播交流信息==
  • ==数据交换、通信==

3、想要达到这个效果,需要什么

  1. 如何==准确的定位网络上的一台主机== 192.168.1.100: 端口,定位到这个计算机上的某个资源。
  2. 找到了这个主机,如何传输数据呢?

JavaWeb : 网页编程 B/S架构

网络编程: TCP/IP C/S架构

4、网络通信要素

如何实现网络的通信?

  • ==通信双方的地址==:
    • IP:
      • 192.168.1.100
    • 端口号
      • 8080
  • ==规则:网络通信的协议==

5、TCP/IP参考模型

image-20210815222644766

6、OSI七层参考模型 与 TCP/IP参考模型

image-20210815225710618

7、小结

  1. 网络编程中两个主要问题
    • 如何准确定位到网络上的一台或多台主机
    • 找到主机之后如何进行通信
  2. 网络编程中的要素
    • IP 和 端口号
    • 网络通信协议
  3. Java 万物皆对象

2、IP——InetAddress

ip地址:InetAddress

  1. ==唯一定位一台网络上计算机==
  2. ==127.0.0.1: 本机localhost==
  3. ip地址的分类:IPV4/IPv6
    • IPV4 127.0.0.1 4个字节组成,0-255 42亿个 30亿都在北美,亚洲4亿。2011年就用尽
    • IPV6 ;128位。8个无符号整数!
  4. 公网-私网

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package cn.bloghut.lesson01;

import java.net.InetAddress;
import java.net.UnknownHostException;

/**
* @description 测试IP
*/
public class TestInetAddress {
public static void main(String[] args) throws Exception {
//查询本机的ip地址
InetAddress localhost = InetAddress.getByName("localhost");
// InetAddress localhost = InetAddress.getByName("127.0.0.1");
// InetAddress localhost = InetAddress.getLocalHost();
System.out.println(localhost);

InetAddress localHost = InetAddress.getLocalHost();
System.out.println(localHost);
System.out.println("=================");
//查询网站ip地址
InetAddress name = InetAddress.getByName("www.baidu.com");
System.out.println(name);
System.out.println("=================");
//常用方法
//System.out.println(name.getAddress());
System.out.println(name.getHostAddress());//获取主机ip地址
System.out.println(name.getHostName());// 获取域名
System.out.println(name.getCanonicalHostName());//获取规范的主机ip地址
}
}

3、端口——InetSocketAddress

==端口表示计算机上的一个程序的进程。==

  1.  一栋楼表示一个ip ,这栋楼里面的 门牌号 就是端口号。
  2. ==不同的进程有不同的端口号==!用来区分软件的。
  3. 端口被规定为:0-65535
  4. TCP ,UDP: 每个都有 0-65535 * 2 ,单个协议下,端口号不能冲突。
  5. 端口分类
    • 公有端口:0-1023
      • HTTP : 80
      • HTTPS :443
      • FTP : 21
      • Telnet: 23
    • 程序注册端口:1024-49151,分配给用户或者程序
      • Tomcat:8080
      • Mysql:3306
      • Oracle:1521
    • 动态、私有:49152-65535
1
2
3
4
netstat -ano #查看所有端口
netstat -ano | findstr "5900" #查看指定的端口
tasklist | findstr "8696" #查看指定端口的进程
Ctrl + Shift + ESC #打开任务管理器

相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package cn.bloghut.lesson01;

import java.net.InetSocketAddress;

/**
* @description TODO
*/
public class TesyInetSocketAddress {
public static void main(String[] args) {

InetSocketAddress socketAddress = new InetSocketAddress("127.0.0.1", 8080);
InetSocketAddress socketAddress1 = new InetSocketAddress("localhost", 8080);

// /127.0.0.1:8080
System.out.println(socketAddress);
// localhost/127.0.0.1:8080
System.out.println(socketAddress1);
System.out.println("====================");

System.out.println(socketAddress.getAddress());//ip地址
System.out.println(socketAddress.getHostName());//主机名称
System.out.println(socketAddress.getHostString());
System.out.println(socketAddress.getPort());//端口

}
}

4、通信协议

协议:约定,就好比我们现在说的是普通话。

网络通信协议:

  1. 速率
  2. 传输码率
  3. 代码结构 
  4. 传输控制

问题:非常的复杂

TCP/IP协议簇:实际上是一组协议

重要:

  • TCP:用户传输协议
  • UDP:用户数据报协议

出名的协议:

  1. TCP
  2. IP

TCP和UDP 对比:

  • TCP:打电话
    • 连接: 稳定
      • 三次握手
        • A:你愁啥?
        • B:瞅你咋地?
        • A:干一次!
      • 四次挥手
        • A:我要断开了 (我要走了)
        • B:我知道你要断开了(你真的要走了吗?)
        • B:你真的断开了吗?(你真的真的要走了吗?)
        • A:我真的断开了 (我真的要走了)

客户端,服务端

传输完成,释放连接、效率低

image-20210816004713109

1、三次握手

  1. 第一次握手:建立连接时,客户端发送syn包(syn=j)到服务器,并进入SYN_SENT状态,等待服务器确认;SYN:同步序列编号(Synchronize Sequence Numbers)。
  2. 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态;
  3. 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED(TCP连接成功)状态,完成三次握手。

2、四次挥手

image-20210816004806220

UDP:发短信

  1. 不连接,不稳定
  2. 客户端、服务端:没有明确的界限
  3. 不管有没有准备好,都可以发给你…
  4. 导弹
  5. DDOS:洪水攻击!(饱和攻击)

3、客户端

  1. 建立连接
  2. 发送消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package cn.bloghut.lesson02;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;

/**
* @description 客户端
*/
public class TcpClientDemo1 {

public static void main(String[] args) throws Exception{
Socket socket = null;
OutputStream os = null;
//要知道服务器地址
try {
InetAddress serverIp = InetAddress.getByName("localhost");
int port = 9999;
//2.创建连接
socket = new Socket(serverIp,port);
//3.发生消息 IO流
os = socket.getOutputStream();
os.write("你好,世界".getBytes());
} catch (IOException e) {
e.printStackTrace();
}finally {
if (os != null) {
os.close();
}
if (socket != null) {
socket.close();
}
}
}
}

4、服务器

  1. 建立服务连接的端口 ServerSocket
  2. 等待用户的连接 accept
  3. 接收用户信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package cn.bloghut.lesson02;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description 服务器端
*/
public class TcpServerDemo01 {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = null;
Socket accept = null;
InputStream is = null;
ByteArrayOutputStream baos = null;
try {
//1. 我得有一个地址
serverSocket = new ServerSocket(9999);
//2.等待客户端连接过来
accept = serverSocket.accept();
//3.读取客户端消息
is = accept.getInputStream();

/*byte[] buf = new byte[1024];
int len;
while ((len = is.read(buf)) != -1 ){
String s = new String(buf, 0, len);
System.out.println(s);
}*/

//管道流
baos = new ByteArrayOutputStream();
byte[] buff = new byte[1024];
int len = -1;

while ((len = is.read(buff)) != -1) {
baos.write(buff, 0, len);
}
System.out.println(baos.toString());

} catch (IOException e) {
e.printStackTrace();
} finally {
if (baos != null) {
baos.close();
}
if (is != null) {
is.close();
}
if (accept != null) {
accept.close();
}
if (serverSocket != null) {
serverSocket.close();
}
}
}
}

5、TCP实现文件上传

1、客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package cn.bloghut.lesson02;

import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

/**
* @description 服务端
*/
public class TcpServerDemo2 {
public static void main(String[] args) throws Exception{
//1.创建服务
ServerSocket serverSocket = new ServerSocket(9999);
//2.监听客户端连接
Socket accept = serverSocket.accept();
//3.获取输入流
InputStream is = accept.getInputStream();

//4.文件输出
FileOutputStream fos = new FileOutputStream(new File("receive.jpg"));//接收文件就要用文件的管道流
byte[] buff = new byte[1024];
int len;
while ((len = is.read(buff)) != -1){
fos.write(buff,0,len);
}

//通过客户端我接收完毕了
OutputStream os = accept.getOutputStream();
os.write("我接收完毕了,你可以断开了".getBytes());

fos.close();
is.close();
accept.close();
serverSocket.close();
}
}

2、服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package cn.bloghut.lesson02;

import java.io.*;
import java.net.InetAddress;
import java.net.Socket;
import java.net.UnknownHostException;

/**
* @description 客户端
*/
public class TcpClientDemo2 {

public static void main(String[] args) throws Exception {
//1.建立连接
Socket socket = new Socket(InetAddress.getByName("127.0.0.1"), 9999);
//2.创建一个输出流
OutputStream os = socket.getOutputStream();
//3.读取文件
FileInputStream is = new FileInputStream(new File("1.jpg"));
byte[] buff = new byte[1024];
int len;
//4.写出文件
while ((len = is.read(buff)) != -1) {
os.write(buff, 0, len);
}

//通知服务器,我已经结束了
socket.shutdownOutput();//我已经传输完了的意思

//确定服务器接收完毕,才能够断开连接
InputStream inputStream = socket.getInputStream();//接收字符、就用字节的管道流
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buff2 = new byte[1024];
int len2;
while ((len2 = inputStream.read(buff)) != -1) {
bos.write(buff2, 0, len2);
}
System.out.println(bos.toString());

//5.释放资源
bos.close();
inputStream.close();
is.close();
os.close();
socket.close();
}
}

6、UDP:发短信,需要IP地址

1、发送端:发送消息——DatagramPacket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package cn.bloghut.lesson3;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

/**
* @description 不需要连接服务器
*/
public class UdpClientDemo1 {
public static void main(String[] args) throws Exception{

//1.建立一个Socket
DatagramSocket socket = new DatagramSocket();
//2.建个包
String msg = "你好啊,服务器";
//3.发送给谁
InetAddress address = InetAddress.getByName("localhost");
int port = 9090;

DatagramPacket packet = new DatagramPacket(msg.getBytes(), 0, msg.getBytes().length, address, port);
//4.发送包
socket.send(packet);
}
}

2、接收端:接收消息——DatagramSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package cn.bloghut.lesson3;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.ServerSocket;

/**
* @description TODO
*/
public class UdpServerDemo1 {
public static void main(String[] args) throws Exception{
//开放端口
DatagramSocket socket = new DatagramSocket(9090);
//接收数据包
byte[] buff =new byte[1024];
DatagramPacket packet = new DatagramPacket(buff, 0, buff.length);

socket.receive(packet);//阻塞接收

System.out.println(packet.getAddress());
System.out.println(new String(packet.getData(),0,packet.getData().length));

socket.close();
}
}

7、UDP 实现聊天实现

1、发送方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package cn.bloghut.chat;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetSocketAddress;

/**
* @description 接收端
*/
public class UdpSenderDemo01 {
public static void main(String[] args) throws Exception {
//获取连接
DatagramSocket socket = new DatagramSocket(8080);
while (true) {
//准备数据
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String data = reader.readLine();
byte[] datas = data.getBytes();
DatagramPacket packet = new DatagramPacket
(datas, 0,datas.length, new InetSocketAddress("localhost", 6666));
//发送数据
socket.send(packet);
if (data.equals("bye")) {
break;
}
}
socket.close();
}
}

2、接收端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package cn.bloghut.chat;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

/**
* @description 接收端
*/
public class UdpReceiveDemo01 {

public static void main(String[] args) throws Exception {
DatagramSocket socket = new DatagramSocket(6666);
while (true) {
//准备接收包裹
byte[] container = new byte[1024];
DatagramPacket packet = new DatagramPacket(container, 0, container.length);
socket.receive(packet);//阻塞式接收包裹


byte[] data = packet.getData();
String receiveData = new String(data, 0, data.length);

System.out.println(receiveData);

//断开连接 bye
if (receiveData.equals("bye")){
break;
}
}
socket.close();
}
}

5、参考文档

B站【狂神说Java笔记】-网络编程

[TOC]

Netty

1、Netty的介绍以及应用场景

1、Netty的基本介绍

  1. Netty 是由 JBOSS 提供的一个 Java 开源框架,现为 Github上的独立项目。

  2. Netty 是一个==异步的==、==基于事件驱动==的==网络应用框架==,用以快速开发高性能、高可靠性的网络 IO 程序

    image-20210816030806875

  3. Netty主要针对在==TCP协议==下,==面向Clients端==的高并发应用,或者==Peer-to-Peer场景==下的大量数据持续传输的应用。

  4. Netty本质是一个==NIO框架==,适用于服务器通讯相关的多种应用场景

  5. 要透彻理解Netty , 需要先学习 NIO , 这样我们才能阅读 Netty 的源码。

    image-20210816030833354

2、Netty的应用场景

1、互联网行业

  1. 互联网行业:在分布式系统中,各个节点之间需要远程服务调用,高性能的 RPC 框架必不可少,Netty 作为异步高性能的通信框架,往往作为基础通信组件被这些 RPC 框架使用

  2. 典型的应用有:阿里分布式服务框架 Dubbo 的 RPC 框架使用 Dubbo 协议进行节点间通信,Dubbo 协议默认使用 Netty 作为基础通信组件,用于实现各进程节点之间的内部通信

    image-20210816031145708

2、游戏行业

  1. 无论是手游服务端还是大型的网络游戏,Java 语言得到了越来越广泛的应用
  2. Netty 作为高性能的基础通信组件,提供了 TCP/UDP 和 HTTP 协议栈,方便定制和开发私有协议栈,账号登录服务器
  3. 地图服务器之间可以方便的通过 Netty 进行高性能的通信

3、大数据领域

  1. 经典的 Hadoop 的高性能通信和序列化组件 Avro(实现数据文件共享) 的 RPC 框架,默认采用 Netty 进行跨界点通信

  2. 它的 Netty Service 基于 Netty 框架二次封装实现。

    image-20210816031437724

4、其它开源项目使用到Netty

网址: https://netty.io/wiki/related-projects.html

image-20210816031742962

3、Netty的学习参考资料

  • 《Netty IN Action》
    • image-20210816031941432
  • Netty权威指南
    • image-20210816032001424

2、Java BIO编程

1、I/O模型

1、I/O模型的基本说明

  • I/O 模型简单的理解:就是用什么样的通道进行数据的发送和接收,很大程度上决定了程序通信的性能

  • Java共支持3种网络编程模型/IO模式:BIONIOAIO

    • Java BIO: 同步并阻塞(传统阻塞型),服务器实现模式为==一个连接一个线程==,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销

      image-20210816032943453

    • Java NIO同步非阻塞,服务器实现模式为==一个线程处理多个请求(连接)==,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理

      image-20210816033058098

    • Java AIO(NIO.2)异步非阻塞,AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用

2、BIO、NIO、AIO适用场景分析

  • BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序简单易理解。
  • NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,弹幕系统,服务器间通讯等。编程比较复杂,JDK1.4开始支持。
  • AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

2、Java BIO 基本介绍

  1. Java BIO 就是传统的java io编程,其相关的类和接口在 java.io
  2. BIO(blocking I/O)**: **同步阻塞,服务器实现模式为==一个连接一个线程==,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)。 【后有应用实例
  3. BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,程序简单易理解

3、Java BIO 工作机制

1、工作原理图

image-20210816033912512

2、BIO编程简单流程

  1. 服务器端启动一个ServerSocket
  2. 客户端启动Socket对服务器进行通信,默认情况下服务器端需要对每个客户建立一个线程与之通讯
  3. 客户端发出请求后,先咨询服务器是否有线程响应,如果没有则会等待,或者被拒绝
  4. 如果有响应,客户端线程会等待请求结束后,在继续执行

4、Java BIO 应用实例

实例说明:

  1. 使用BIO模型编写一个服务器端,监听6666端口,当有客户端连接时,就启动一个线程与之通讯。
  2. 要求使用线程池机制改善,可以连接多个客户端;
  3. 服务器端可以接收客户端发送的数据(telnet 方式即可)。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package com.awo.bio;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BIOServer {
public static void main(String[] args) throws IOException {

//线程池机制

//思路
//1. 创建一个线程池
//2. 如果有客户端连接,就创建一个线程,与之通讯(单独写一个方法)

//1. 创建一个线程池
ExecutorService pool = Executors.newCachedThreadPool();

//创建ServerSocket
ServerSocket serverSocket = new ServerSocket(6666);

System.out.println("服务器启动了");

while (true) {

//监听,等待客户端连接
System.out.println("等待连接....");
final Socket socket = serverSocket.accept();
System.out.println("连接到一个客户端");

//就创建一个线程,与之通讯(单独写一个方法)
pool.execute(() -> {
//可以和客户端通讯
handler(socket);
});
}
}

//编写一个handler方法,和客户端通讯
public static void handler(Socket socket) {
InputStream is = null;
try {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
byte[] bytes = new byte[1024];
//通过socket 获取输入流
is = socket.getInputStream();

//循环的读取客户端发送的数据
while (true) {
System.out.println("线程信息 id =" + Thread.currentThread().getId() + " 名字=" + Thread.currentThread().getName());
System.out.println("read....");
int read;
if ((read = is.read(bytes)) != -1) {
//输出客户端发送的数据
System.out.println(new String(bytes, 0, read));
} else {
break;
}

}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (is == null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println("关闭和client的连接");
if (socket == null) {
try {
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}

5、Java BIO 问题分析

  1. 每个请求都需要创建独立的线程,与对应的客户端进行数据 Read,业务处理,数据 Write 。
  2. 当并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
  3. 连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费

3、Java NIO编程

1、Java NIO 基本介绍

  1. Java NIO 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞

  2. NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写。

    image-20210816204510649

  3. NIO 有三大核心部分:Channel(通道)Buffer(缓冲区)Selector(选择器)

  4. NIO是 ==面向缓冲区== ,或者==面向 块 编程==的。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动,这就增加了处理过程中的灵活性,使用它可以提供非阻塞式的高伸缩性网络

  5. Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。【后面有案例说明

  6. 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有10000个请求过来,根据实际情况,可以分配50或者100个线程来处理。不像之前的阻塞IO那样,非得分配10000个。

  7. HTTP2.0使用了多路复用的技术,做到同一个连接并发处理多个请求,而且并发请求的数量比HTTP1.1大了好几个数量级。

2、NIO 和 BIO的比较

  1. BIO 以==流==的方式处理数据,而 NIO 以==块==的方式处理数据,块 I/O 的效率比流 I/O 高很多
  2. BIO 是==阻塞==的,NIO 则是==非阻塞==的
  3. BIO基于==字节流==和==字符流==进行操作,而 NIO 基于 ==Channel(通道)==和 ==Buffer(缓冲区)==进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于==监听多个通道的事件==(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道

3、NIO 三大核心原理示意图

一张图描述NIO 的 SelectorChannelBuffer 的关系:

image-20210816205036305

SelectorChannelBuffer 的关系图(简单版)关系图的说明:

  1. 每个channel 都会对应一个Buffer
  2. Selector 对应一个线程, 一个线程对应多个channel(连接)
  3. 该图反应了有三个channel 注册到 该selector //程序
  4. 程序切换到哪个channel 是有事件决定的,Event 就是一个重要的概念
  5. Selector 会根据不同的事件,在各个通道上切换
  6. Buffer 就是一个内存块 ,底层是有一个数组
  7. 数据的读取写入是通过Buffer,这个和BIO有着本质的不同:BIO 中要么是输入流,要么是输出流,不能双向,但是NIO的Buffer 是可以读也可以写,需要 flip 方法切换
  8. channel 是双向的,可以返回底层操作系统的情况,比如Linux:底层的操作系统通道就是双向的。

4、缓冲区(Buffer)

1、基本介绍

缓冲区(Buffer):缓冲区本质上是一个可以读写数据的内存块,可以理解成是一个容器对象(含数组)**,该对象提供了一组方法,可以更轻松地使用内存块,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况**。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer,如图: 【后面举例说明】

image-20210816220148414

2、Buffer 类及其子类

1、Buffer类继承关系

在 NIO 中,Buffer 是一个顶层父类,它是一个抽象类,类的层级关系图

  • 常用Buffer子类一览
    • ByteBuffer:存储字节数据到缓冲区
    • ShortBuffer:存储字符串数据到缓冲区
    • CharBuffer:存储字符数据到缓冲区
    • IntBuffer:存储整数数据到缓冲区
    • LongBuffer:存储长整型数据到缓冲区
    • DoubleBuffer:存储小数到缓冲区
    • FloatBuffer:存储小数到缓冲区
    • image-20210816222453542
    • image-20210816222527728
  • 每一个Buffer的实现类都有一个属性:hb(不同实现类该属性的类型不同,但都是一个数组),数据实际上就是存放在hb数组里面的
2、Buffer的四个主要属性

Buffer类定义了所有的缓冲区都具有的四个属性来提供关于其所包含的数据元素的信息:

  • Capacity:容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变
  • Limit:表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作(左闭右开)。且极限是可以修改的
  • Position:位置,下一个要被读或写的元素的索引,每次读写缓冲区数据时都会改变改值,为下次读写作准备
  • Mark:标记(很少主动修改)
1
2
3
4
5
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;

其中最重要的flip()方法:用来切换Buffer的读写(其中对于limit是一个“左闭右开区间”)

1
2
3
4
5
6
7
8
public final Buffer flip() {
// 将当前所在位置设置成缓存区的终点
limit = position;
// 将当前位置归0
position = 0;
mark = -1;
return this;
}

左闭右开:

image-20210816233421568

3、Buffer类相关方法一览
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public abstract class Buffer {
//JDK1.4时,引入的api
public final int capacity();//返回此缓冲区的容量(重要)
public final int position();//返回此缓冲区的位置(重要)
public final Buffer position (int newPosition);//设置此缓冲区的位置(重要)
public final int limit();//返回此缓冲区的限制(重要)
public final Buffer limit (int newLimit);//设置此缓冲区的限制(重要)

public final Buffer mark();//在此缓冲区的位置设置标记
public final Buffer reset();//将此缓冲区的位置重置为以前标记的位置

public final Buffer clear();//清除此缓冲区, 即将各个标记恢复到初始状态,但是数据并没有真正擦除, 后面操作会覆盖(重要)
public final Buffer flip();//反转此缓冲区(重要)

public final Buffer rewind();//重绕此缓冲区
public final int remaining();//返回当前位置与限制之间的元素数

public final boolean hasRemaining();//告知在当前位置和限制之间是否有元素(重要)
public abstract boolean isReadOnly();//告知此缓冲区是否为只读缓冲区(重要)

//JDK1.6时引入的api
public abstract boolean hasArray();//告知此缓冲区是否具有可访问的底层实现数组(重要)
public abstract Object array();//返回此缓冲区的底层实现数组(重要)

public abstract int arrayOffset();//返回此缓冲区的底层实现数组中第一个缓冲区元素的偏移量
public abstract boolean isDirect();//告知此缓冲区是否为直接缓冲区
}

注:什么是直接缓冲区?

  • 直接缓冲区指的是操作系统的缓冲区
  • 而平常的缓冲区通常都是JVM分配的缓冲区
4、ByteBuffer(最常用)

从前面可以看出对于 Java 中的基本数据类型(boolean除外),都有一个 Buffer 类型与之相对应,最常用的自然是ByteBuffer 类(二进制数据),该类的主要方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class ByteBuffer {
//缓冲区创建相关api
public static ByteBuffer allocateDirect(int capacity);//创建直接缓冲区(重要)
public static ByteBuffer allocate(int capacity);//设置缓冲区的初始容量(重要)

public static ByteBuffer wrap(byte[] array);//把一个数组放到缓冲区中使用
//构造初始化位置offset和上界length的缓冲区
public static ByteBuffer wrap(byte[] array,int offset, int length);

//缓存区存取相关API
public abstract byte get( );//从当前位置position上get,get之后,position会自动+1(重要)
public abstract byte get (int index);//从绝对位置get(重要)
//从当前位置上添加,put之后,position会自动+1(重要)
public abstract ByteBuffer put (byte b);
public abstract ByteBuffer put (int index, byte b);//从绝对位置上put(重要)
}

image-20210816234508355

5、通道(Channel)

1、基本介绍

  1. NIO的通道类似于流,但有些区别如下:

    • 通道可以同时进行读写,而流只能读或者只能写
    • 通道可以实现异步读写数据
    • 通道可以从缓冲读数据,也可以写数据到缓冲:
      • image-20210816235045647
  2. BIO 中的 stream 是单向的,例如 FileInputStream 对象只能进行读取数据的操作,而 NIO 中的通道(Channel)是双向的,可以读操作,也可以写操作。

  3. Channel在NIO中是一个接口:

    • public interface Channel extends Closeable{} 
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32

      4. 常用的 Channel 类有:

      - `FileChannel`:用于文件的数据读写
      - `DatagramChannel`:用于 UDP 的数据读写
      - `ServerSocketChannel`:用于 TCP 的数据读写
      - ServerSocketChanne 类似 ServerSocket
      - `SocketChannel`:用于 TCP 的数据读写
      - SocketChannel 类似 Socket

      ![image-20210817001954099](Netty/image-20210817001954099.png)

      ![image-20210817002125912](Netty/image-20210817002125912.png)



      #### 2、FileChannel类

      FileChannel主要用来对本地文件进行 IO 操作,常见的方法有:

      ```java
      // 从通道读取数据并放到缓冲区中
      public int read(ByteBuffer dst);

      // 把缓冲区的数据写到通道中
      public int write(ByteBuffer src);

      // 从目标通道中复制数据到当前通道(可以用来做文件的拷贝,速度很快)
      public long transferFrom(ReadableByteChannel src, long position, long count);

      // 把数据从当前通道复制给目标通道(底层实现了零拷贝,速度很快)
      public long transferTo(long position, long count, WritableByteChannel target);

3、应用实例

1、应用实例1——本地文件写数据

实例要求:

  1. 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 “hello,world” 写入到file01.txt 中
  2. 文件不存在就创建

分析:

image-20210817012929177

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.awo.nio;

import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel01 {

public static void main(String[] args) throws IOException {
String str = "hello,world";
//创建一个输出流->channel

FileOutputStream fos = new FileOutputStream("D:\\编程\\netty\\src\\file01.txt");

//通过 fileOutputStream 获取 对应的 FileChannel
//这个 fileChannel 真实 类型是 FileChannelImpl
FileChannel fileChannel = fos.getChannel();

//创建一个缓冲区 ByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

//将 str 放入 byteBuffer
byteBuffer.put(str.getBytes());

//对byteBuffer 进行flip
byteBuffer.flip();

//将byteBuffer 数据写入到 fileChannel
fileChannel.write(byteBuffer);
fos.close();
}
}
2、应用实例2——本地文件读数据

实例要求:

  1. 使用前面的ByteBuffer(缓冲) 和 FileChannel(通道), 将 file01.txt 中的数据读入到程序,并显示在控制台屏幕
  2. 假定文件已经存在

分析:

image-20210817040423887

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.awo.nio;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel02 {
public static void main(String[] args) throws IOException {

//创建文件的输入流
File file = new File("D:\\编程\\netty\\src\\file01.txt");
FileInputStream fis = new FileInputStream(file);

//通过fileInputStream 获取对应的FileChannel -> 实际类型 FileChannelImpl
FileChannel channel = fis.getChannel();

//创建缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

//将 通道的数据读入到Buffer
channel.read(byteBuffer);

//将byteBuffer 的 字节数据 转成String
System.out.println(new String(byteBuffer.array()));
fis.close();
}
}
3、应用实例3——使用一个Buffer完成文件读取

实例要求:

  1. 使用 FileChannel(通道) 和 方法 read , write,完成文件的拷贝
  2. 拷贝一个文本文件 1.txt , 放在项目下即可

分析:

image-20210817040359556

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.awo.nio;

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class NIOFileChannel03 {
public static void main(String[] args) throws IOException {

File file = new File("1.txt");
FileInputStream fis = new FileInputStream(file);
FileChannel readChannel = fis.getChannel();

FileOutputStream fos = new FileOutputStream("2.txt");
FileChannel writeChannel = fos.getChannel();

ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());

//循环读取
while (true) {

//这里有一个重要的操作,一定不要忘了
//清空buffer,其实就是复位一下属性值
byteBuffer.clear();
int read = readChannel.read(byteBuffer);
//表示读完
if (read == -1) {
break;
}
//将buffer 中的数据写入到 fileChannel02 -- 2.txt
byteBuffer.flip();
writeChannel.write(byteBuffer);
}

//关闭相关的流
fis.close();
fos.close();
}
}

clear()的相关代码:

1
2
3
4
5
6
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
4、应用实例4——拷贝文件transferFrom 方法

实例要求:

  1. 使用 FileChannel(通道) 和 方法 transferFrom ,完成文件的拷贝
  2. 拷贝一张图片

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.awo.nio;

import java.io.*;
import java.nio.channels.FileChannel;

public class NIOFileChannel04 {
public static void main(String[] args) throws IOException {
//创建相关流
File file = new File("Koala.jpg");
FileInputStream fis = new FileInputStream(file);
FileOutputStream fos = new FileOutputStream("Koala01.jpg");

//获取各个流对应的filechannel
FileChannel sourceCh = fis.getChannel();
FileChannel destCh = fos.getChannel();

//使用transferForm完成拷贝
destCh.transferFrom(sourceCh, 0, sourceCh.size());
//关闭相关通道和流
destCh.close();
sourceCh.close();
fos.close();
fis.close();
}
}

4、关于Buffer 和 Channel的注意事项和细节

  1. ByteBuffer 支持类型化的put 和 get, put 放入的是什么数据类型,get就应该使用相应的数据类型来取出,否则可能有 BufferUnderflowException 异常。
  2. 可以将一个普通Buffer 转成只读Buffer:使用buffer.asReadOnlyBuffer();方法将一个普通buffer转换成只读Buffer
    • 如果在只读Buffer当中添加数据,会抛出一个ReadOnlyBufferException异常
  3. NIO 还提供了 MappedByteBuffer, 可以让文件直接在内存(堆外的内存)中进行修改, 而如何同步到文件由NIO 来完成。调用channel.map(FileChannel.MapMode.READ_WRITE, 0, 6);方法生成一个MappedByteBuffer对象
    • 注意:map的几个参数
      • MapMode mode:映射的模式——与上面创建流的模式对应
      • long position:从哪里开修改,即修改的开始位置
      • long size:修改的大小,如果修改的地方超过设置的修改大小,会抛出一个IndexOutOfBoundsException异常
  4. 前面我们讲的读写操作,都是通过一个Buffer 完成的,NIO 还支持 通过多个Buffer (即 Buffer 数组) 完成读写操作,即 ScatteringGathering
    • Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散读取]
    • Gathering: 从buffer读取数据时,可以采用buffer数组,依次读取 [聚集写入]
1、关于MappedByteBuffer的相关示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.awo.nio;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

/**
* 说明:MappedByteBuffer 可让文件直接在内存(堆外内存)修改, 操作系统不需要拷贝一次
*/
public class MappedByteBufferTest {
public static void main(String[] args) throws IOException {
RandomAccessFile randomAccessFile = new RandomAccessFile("1.txt", "rw");
//获取对应的通道
FileChannel channel = randomAccessFile.getChannel();

/**
* 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式
* 参数2: 0 : 可以直接修改的起始位置
* 参数3: 6: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存
* 可以直接修改的范围就是 0-6
* 实际类型 DirectByteBuffer
*/
MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 6);

mappedByteBuffer.put(0, (byte) 'H');
mappedByteBuffer.put(5, (byte) '-');
// 抛出异常:IndexOutOfBoundsException
// mappedByteBuffer.put(6, (byte) '9');

randomAccessFile.close();
System.out.println("修改成功");
}
}
2、关于 Scattering 和 Gathering的相关示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.awo.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Arrays;

/**
* Scattering:将数据写入到buffer时,可以采用buffer数组,依次写入 [分散读取]
* Gathering: 从buffer读取数据时,可以采用buffer数组,依次读取 [聚集写入]
*/
public class ScatteringAndGatheringTest {
public static void main(String[] args) throws IOException {
//使用 ServerSocketChannel 和 SocketChannel 网络
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress inetSocketAddress = new InetSocketAddress(7777);

//绑定端口到socket ,并启动
serverSocketChannel.socket().bind(inetSocketAddress);

//创建buffer数组
ByteBuffer[] byteBuffers = new ByteBuffer[2];
byteBuffers[0] = ByteBuffer.allocate(5);
byteBuffers[1] = ByteBuffer.allocate(3);

//等客户端连接(telnet)
SocketChannel socketChannel = serverSocketChannel.accept();
//假定从客户端接收8个字节
int messageLength = 8;

//循环的读取
while (true) {
int byteRead = 0;
while (byteRead < messageLength) {
long read = socketChannel.read(byteBuffers);
//累计读取的字节数
byteRead += read;
System.out.println("byteRead=" + byteRead);
//使用流打印, 看看当前的这个buffer的position 和 limit
Arrays.asList(byteBuffers).stream().map(buffer -> "position=" + buffer.position() + ", limit=" + buffer.limit())
.forEach(System.out::println);
}
//将所有的buffer进行flip
Arrays.asList(byteBuffers).forEach(buffer -> buffer.flip());

//将数据读出显示到客户端
long byteWirte = 0;
while (byteWirte < messageLength) {
long write = socketChannel.write(byteBuffers);
byteWirte += write;
}

//将所有的buffer 进行clear
Arrays.asList(byteBuffers).forEach(buffer -> buffer.clear());
System.out.println("byteRead:=" + byteRead + " byteWrite=" + byteWirte + ", messagelength" + messageLength);
}

}
}

6、Selector(选择器)

1、基本介绍

  1. Java 的 NIO,用非阻塞的 IO 方式。可以用一个线程,处理多个的客户端连接,就会使用到Selector选择器
  2. **Selector 能够检测多个注册的通道上是否有事件发生(注意:多个Channel以事件的方式可以注册到同一个Selector)**,如果有事件发生,便获取事件然后针对每个事件进行相应的处理。这样就可以只用一个单线程去管理多个通道,也就是管理多个连接和请求。
  3. 只有在 连接/通道 真正有读写事件发生时,才会进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程
  4. 避免了多线程之间的上下文切换导致的开销

2、Selector示意图和特点说明

1、Selector示意图

image-20210817171431861

2、特点说明
  1. Netty 的 IO 线程 NioEventLoop 聚合了 Selector(选择器,也叫多路复用器),可以同时并发处理成百上千个客户端连接。
  2. 当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。
  3. 线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。
  4. 由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。
  5. 一个 I/O 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 I/O 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

3、Selector类相关方法

Selector 类是一个抽象类,以下为Selector的相关方法:

image-20210817172751502

常用方法和说明如下:

1
2
3
4
5
6
7
8
9
10
public abstract class Selector implements Closeable { 
//得到一个选择器对象
public static Selector open();

//监控所有注册的通道,当其中有 IO 操作可以进行时,将对应的 SelectionKey 加入到内部集合中并返回,参数用来设置超时时间
public int select(long timeout);

//从内部集合中得到所有的 SelectionKey
public Set<SelectionKey> selectedKeys();
}

4、注意事项

  1. NIO中的 ServerSocketChannel功能类似ServerSocket,SocketChannel功能类似Socket

  2. selector 相关方法说明:

    • selector.select()//阻塞
      
      selector.select(1000);//阻塞1000毫秒,在1000毫秒后返回
      
      selector.wakeup();//唤醒selector
      
      selector.selectNow();//不阻塞,立马返还
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      106
      107
      108
      109
      110
      111
      112
      113
      114
      115
      116
      117
      118



      ### 7、NIO 非阻塞 网络编程

      #### 1、NIO 非阻塞 网络编程原理分析图

      NIO 非阻塞 网络编程相关的(`Selector`、`SelectionKey`、`ServerScoketChannel`和`SocketChannel`) 关系梳理图:

      ![image-20210817173445617](Netty/image-20210817173445617.png)

      对上图的说明:

      1. 当客户端连接时,会通过 ServerSocketChannel 得到 SocketChannel
      2. Selector 进行监听 select 方法, 返回有事件发生的通道的个数.
      3. 将 socketChannel 注册到Selector上,register(Selector sel, int ops), 一个selector上可以注册多个SocketChannel
      - 其中register(Selector sel, int ops)方法的两个参数:
      - Selector sel:想要注册到的选择器
      - int ops:SelectionKey与Channel的注册关系
      - `int OP_ACCEPT`:有新的网络连接可以 accept,值为 16
      - `int OP_CONNECT`:代表连接已经建立,值为 8
      - `int OP_READ`:代表读操作,值为 1
      - `int OP_WRITE`:代表写操作,值为 4
      4. 注册后返回一个 SelectionKey,会和该Selector 关联(集合)
      5. 进一步得到各个 SelectionKey (有事件发生)
      6. 在通过 SelectionKey 反向获取 SocketChannel,方法 channel()
      7. 可以通过channel()方法得到的 channel , 完成业务处理



      #### 2、NIO 非阻塞 网络编程快速入门

      案例要求:

      1. 编写一个 NIO 入门案例,实现服务器端和客户端之间的数据简单通讯(非阻塞)
      2. 目的:理解NIO非阻塞网络编程机制

      代码示例:

      - 服务端:

      ```java
      package com.awo.nio;

      import java.io.IOException;
      import java.net.InetSocketAddress;
      import java.nio.ByteBuffer;
      import java.nio.channels.*;
      import java.util.Iterator;
      import java.util.Set;

      public class NIOServer {

      public static void main(String[] args) throws IOException {
      //创建ServerSocketChannel -> ServerSocket
      ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
      //得到一个Selector对象
      Selector selector = Selector.open();
      //绑定一个端口6666, 在服务器端监听
      serverSocketChannel.socket().bind(new InetSocketAddress(6666));
      //设置为非阻塞
      serverSocketChannel.configureBlocking(false);
      //把 serverSocketChannel 注册到 selector 关心 事件为 OP_ACCEPT
      serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

      // 1
      System.out.println("注册后的Selectionkey 数量=" + selector.keys().size());

      //循环等待客户端连接
      while (true) {

      //这里我们等待1秒,如果没有事件发生, 返回
      if (selector.select(1000) == 0) {
      //没有事件发生
      System.out.println("服务器等待了1秒,无连接");
      continue;
      }
      //如果返回的>0, 就获取到相关的 selectionKey集合
      //1.如果返回的>0, 表示已经获取到关注的事件
      //2. selector.selectedKeys() 返回关注事件的集合
      // 通过 selectionKeys 反向获取通道
      Set<SelectionKey> selectionKeys = selector.selectedKeys();
      System.out.println("selectionKeys 数量 = " + selectionKeys.size());
      //遍历 Set<SelectionKey>, 使用迭代器遍历
      Iterator<SelectionKey> keyIterator = selectionKeys.iterator();
      while (keyIterator.hasNext()) {
      //获取到SelectionKey
      SelectionKey selectionKey = keyIterator.next();
      //根据key 对应的通道发生的事件做相应处理
      //如果是 OP_ACCEPT, 有新的客户端连接
      if (selectionKey.isAcceptable()) {
      //该该客户端生成一个 SocketChannel
      SocketChannel socketChannel = serverSocketChannel.accept();
      System.out.println("客户端连接成功 生成了一个 socketChannel " + socketChannel.hashCode());
      //将 SocketChannel 设置为非阻塞
      socketChannel.configureBlocking(false);
      //将socketChannel 注册到selector, 关注事件为 OP_READ, 同时给socketChannel关联一个Buffer
      ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
      socketChannel.register(selector, SelectionKey.OP_READ, byteBuffer);
      //2,3,4..
      System.out.println("客户端连接后 ,注册的selectionkey 数量=" + selector.keys().size());
      }
      //发生 OP_READ
      if (selectionKey.isReadable()) {
      //通过key 反向获取到对应channel
      SocketChannel channel = (SocketChannel) selectionKey.channel();
      //获取到该channel关联的buffer
      ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
      channel.read(buffer);
      System.out.println("form 客户端 " + new String(buffer.array()));
      }

      //手动从集合中移动当前的selectionKey, 防止重复操作
      keyIterator.remove();
      }
      }
      }
      }
  • 客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.awo.nio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;

public class NIOClient {
public static void main(String[] args) throws IOException {
//得到一个网络通道
SocketChannel socketChannel = SocketChannel.open();
//设置非阻塞
socketChannel.configureBlocking(false);
//提供服务器端的ip 和 端口
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1", 6666);
//连接服务器
if (!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()) {
System.out.println("因为连接需要时间,客户端不会阻塞,可以做其它工作..");
}
}
//...如果连接成功,就发送数据
String str = "hello, world";
//Wraps a byte array into a buffer
ByteBuffer byteBuffer = ByteBuffer.wrap(str.getBytes());
//发送数据,将 buffer 数据写入 channel
socketChannel.write(byteBuffer);
System.in.read();
}
}

8、SelectionKey

1、SelectionKey和网络通道的注册关系

SelectionKey,表示 Selector 和网络通道的注册关系, 共四种:

  • int OP_ACCEPT:有新的网络连接可以 accept,值为 16
  • int OP_CONNECT:代表连接已经建立,值为 8
  • int OP_READ:代表读操作,值为 1
  • int OP_WRITE:代表写操作,值为 4

相关源代码:

1
2
3
4
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

2、SelectionKey相关方法

image-20210817224717376

其中几个比较常用的方法:

1
2
3
4
5
6
7
8
9
public abstract class SelectionKey {
public abstract Selector selector();//得到与之关联的 Selector 对象
public abstract SelectableChannel channel();//得到与之关联的通道
public final Object attachment();//得到与之关联的共享数据
public abstract SelectionKey interestOps(int ops);//设置或改变监听事件
public final boolean isAcceptable();//是否可以 accept
public final boolean isReadable();//是否可以读
public final boolean isWritable();//是否可以写
}

9、ServerSocketChannel

ServerSocketChannel 在服务器端监听新的客户端 Socket 连接

ServerSocketChannel相关方法如下:

image-20210817225056336

常用方法以及说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class ServerSocketChannel extends AbstractSelectableChannel  implements NetworkChannel{
// 得到一个 ServerSocketChannel 通道(静态方法)
public static ServerSocketChannel open();

// 设置服务器端端口号
public final ServerSocketChannel bind(SocketAddress local);

// 设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);

// 接受一个连接,返回代表这个连接的通道对象
public SocketChannel accept();

// 注册一个选择器并设置监听事件
public final SelectionKey register(Selector sel, int ops);
}

10、SocketChannel

SocketChannel,网络 IO 通道,具体负责进行读写操作。NIO 把缓冲区的数据写入通道,或者把通道里的数据读到缓冲区。

相关方法如下:

image-20210817225834704

常用方法以及说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class SocketChannel extends AbstractSelectableChannel implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel{

//得到一个 SocketChannel 通道
public static SocketChannel open();

//设置阻塞或非阻塞模式,取值 false 表示采用非阻塞模式
public final SelectableChannel configureBlocking(boolean block);

//连接服务器
public boolean connect(SocketAddress remote);

//如果上面的方法连接失败,接下来就要通过该方法完成连接操作
public boolean finishConnect();

//往通道里写数据
public int write(ByteBuffer src);

//从通道里读数据
public int read(ByteBuffer dst);

//注册一个选择器并设置监听事件,最后一个参数可以设置共享数据
public final SelectionKey register(Selector sel, int ops, Object att);

//关闭通道
public final void close();
}

11、NIO 网络编程应用实例——群聊系统

1、实例要求

  1. 编写一个 NIO 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
  2. 实现多人群聊
  3. 服务器端:可以监测用户上线,离线,并实现消息转发功能
  4. 客户端:通过channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
  5. 目的:进一步理解NIO非阻塞网络编程机制

2、实例需求图

image-20210817230238423

3、分析

  1. 先编写服务器端
    1. 服务器启动并监听 6667
    2. 服务器接收客户端信息,并实现转发 [处理上线和离线]
    3. 其中转发注意需要排除发送消息的客户端
  2. 编写客户端
    1. 连接服务器
    2. 发送消息
    3. 接收服务器消息

4、代码

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
package com.awo.nio.groupchat;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

public class GroupChatServer {
// 定义属性

private ServerSocketChannel listenChannel;
private Selector selector;
private static final int PORT = 6667;

// 构造器
// 初始化工作
public GroupChatServer() {
try {
// ServerSocketChannel
listenChannel = ServerSocketChannel.open();
// 得到选择器
selector = Selector.open();
// 绑定端口
listenChannel.socket().bind(new InetSocketAddress(PORT));
// 设置非阻塞模式
listenChannel.configureBlocking(false);
// 将该listenChannel 注册到selector
listenChannel.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 监听
*/
public void listen() {
System.out.println("监听线程: " + Thread.currentThread().getName());

try {
// 循环处理
while (true) {
int count = selector.select();
// 有事件处理
if (count > 0) {
// 得到SelectionKeys的迭代器
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
// 取出selectionkey
SelectionKey key = iterator.next();
// 监听到accept
if (key.isAcceptable()) {
// 得到socketChannel
SocketChannel socketChannel = listenChannel.accept();
// 设置非阻塞模式
socketChannel.configureBlocking(false);
// 将该 socketChannel 注册到 selector
socketChannel.register(selector, SelectionKey.OP_READ);
// 提示
System.out.println(socketChannel.getRemoteAddress() + " 上线 ");
}
// 通道发送read事件,即通道是可读的状态
if (key.isReadable()) {
// 调用readData处理读事件
readData(key);
}
// 当前的key 删除,防止重复处理
iterator.remove();
}
} else {
System.out.println("等待....");
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
//发生异常处理....
}
}

/**
* 读取客户端消息
* @param key
*/
private void readData(SelectionKey key) {
// 定义一个SocketChannel
SocketChannel channel = null;

try {
// 取到关联的channel
channel = (SocketChannel) key.channel();
// 创建buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从buffer读取数据到channel
int count = channel.read(buffer);
// 根据count的值做处理
if (count > 0) {
// 把缓存区的数据转成字符串
String msg = new String(buffer.array());
// 输出该消息
System.out.println("form 客户端: " + msg);
// 向其它的客户端转发消息(去掉自己), 专门写一个方法来处理
sendInfoToOtherClients(msg, channel);
}
} catch (IOException e) {
try {
System.out.println(channel.getRemoteAddress() + " 离线了..");
// 取消注册
key.cancel();
} catch (IOException ioException) {
ioException.printStackTrace();
} finally {
// 关闭通道
try {
channel.close();
} catch (IOException ioException) {
ioException.printStackTrace();
}
}
}
}

/**
* 向其它的客户端转发消息(去掉自己)
* @param msg 转发的消息
* @param self 自己
*/
private void sendInfoToOtherClients(String msg, SocketChannel self) {
System.out.println("服务器转发消息中...");
System.out.println("服务器转发数据给客户端线程: " + Thread.currentThread().getName());
// 遍历 所有注册到selector 上的 SocketChannel,并排除 self
for (SelectionKey key : selector.keys()) {
//通过 key 取出对应的 SocketChannel
Channel targetChannel = key.channel();

//排除自己
if (targetChannel instanceof SocketChannel && targetChannel != self) {
try {
// 转型
SocketChannel dest = (SocketChannel) targetChannel;
// 将msg 存储到buffer
ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
// 将buffer 的数据写入 通道
dest.write(buffer);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

public static void main(String[] args) {
//创建服务器对象
GroupChatServer groupChatServer = new GroupChatServer();
groupChatServer.listen();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
package com.awo.nio.groupchat;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Scanner;

public class GroupChatClient {

//定义相关的属性
private static final String HOST = "127.0.0.1";
private static final int PORT = 6667;
private SocketChannel socketChannel;
private Selector selector;
private String username;

//构造器, 完成初始化工作


public GroupChatClient() {
try {
socketChannel = SocketChannel.open(new InetSocketAddress(HOST, PORT));
socketChannel.configureBlocking(false);
selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_READ);
username = socketChannel.getLocalAddress().toString().substring(1);
System.out.println(username + " is ok...");
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 向服务器发送消息
*
* @param info
*/
public void sendInfo(String info) {
info = username + " 说:" + info;
try {
socketChannel.write(ByteBuffer.wrap(info.getBytes()));
} catch (IOException e) {
e.printStackTrace();
}
}

/**
* 读取从服务器端回复的消息
*/
public void readInfo() {
try {
int readChannels = selector.select();
// 有可以用的通道
if (readChannels > 0) {
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isReadable()) {
// 得到相关的通道
SocketChannel channel = (SocketChannel) key.channel();
// 得到一个Buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读取
channel.read(buffer);
// 把读到的缓冲区的数据转成字符串
String msg = new String(buffer.array());
System.out.println(msg.trim());
}
// 删除当前的selectionKey, 防止重复操作
iterator.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
GroupChatClient groupChatClient = new GroupChatClient();
// 启动一个线程, 每个3秒,读取从服务器发送数据
new Thread(() -> {
while (true) {
groupChatClient.readInfo();
try {
Thread.sleep(3000);
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();

// 发送数据给服务器端
Scanner scanner = new Scanner(System.in);
while (scanner.hasNextLine()) {
String s = scanner.nextLine();
groupChatClient.sendInfo(s);
}
}
}

12、NIO与零拷贝

1、零拷贝基本介绍

  1. 零拷贝是网络编程的关键,很多性能优化都离不开零拷贝。
  2. 在 Java 程序中,常用的零拷贝有 mmap(内存映射)sendFile。那么,他们在 OS 里,到底是怎么样的一个的设计?我们分析 mmap 和 sendFile 这两个零拷贝
  3. 另外我们看下NIO 中如何使用零拷贝

2、传统IO数据读写

代码:

1
2
3
4
5
6
7
8
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");

byte[] arr = new byte[(int) file.length()];
raf.read(arr);

Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);

3、传统IO模型

image-20210818031152788

注意:

  1. DMA:direct memory access——直接内存拷贝(不使用CPU)
  2. 这个IO经过了四次拷贝(两次CPU拷贝、两次DMA拷贝)和三次状态切换(用户态->内核态->用户态->内核态),代价较高
    • 四次拷贝
      1. 第一次: 从硬盘 经过 DMA 拷贝 到 kernel buffer (内核buferr)
      2. 第二次: 从kernel buffer 经过cpu 拷贝到 user buffer,比如拷贝到应用程序
      3. 第三次: 从user buffer 拷贝到 socket buffer
      4. 第四次: 从socket buffer 拷贝到 protocol engine 协议栈
    • 三次状态切换
      1. 第一次状态切换: 用户态 —> 内核态 (或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
      3. 第三次状态切换: 用户态—> 内核态
  3. 有一个观点认为状态切换变成了四次(最后需要从内核态切换为用户态)
    • 第四次状态切换:内核态—> 用户态

4、mmap优化

mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数

image-20210818032518521

注意:

  1. 通过mmap内存映射优化之后,拷贝次数变成了3次,状态切换还是3次
    • 三次拷贝
      1. 第一次拷贝: DMA拷贝,从硬件拷贝到内核空间
        • 因为user buffer 与kernel buffer共享数据 ,所以不需要将数据从kernel buffer 拷贝到 user buffer , 数据可以直接在内核空间修改
      2. 第二次拷贝: kernel buffer 中的数据经过 cpu 拷贝到 socket buffer
      3. 第三次拷贝: socket buffer 过DMA拷贝到protocol engine 协议栈
    • 三次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
      3. 第三次状态切换: 用户态—> 内核态
  2. 有一个观点认为状态切换变成了四次(最后需要从内核态切换为用户态)
    • 第四次状态切换:内核态—> 用户态

5、sendFile优化

Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。具体如下图和小结:

image-20210818033948497

注意:

  • Linux 2.1 版本中,通过sendFile优化之后,拷贝次数变成了3次,状态切换还是2次
    • 三次拷贝
      1. 第一次拷贝: DMA拷贝,从硬件拷贝到内核空间
      2. 第二次拷贝: kernel buffer 中的数据经过 cpu 拷贝到 socket buffer
      3. 第三次拷贝: socket buffer 过DMA拷贝到protocol engine 协议栈
    • 两次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态
        • 由于和用户态完全无关,所以就不用切换到用户态后再切换到内核态了,减少了一次上下文切换

注:

  • 零拷贝从操作系统角度,是没有cpu 拷贝(DMA不可避免)
  • Linux 2.1 版本 提供了 sendFile 函数并没有完全实现零拷贝(存在CPU拷贝)

Linux 在 2.4 版本中,做了一些修改,避免了从内核缓冲区拷贝到 Socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。具体如下图和小结:

image-20210818040744312

注意:

  • Linux 在 2.4 版本中,通过sendFile优化之后,拷贝次数变成了2次,状态切换还是2次
    • 两次拷贝
      1. 第一次拷贝: DMA拷贝,将数据从硬盘拷贝到kernel buffer
      2. 第二次拷贝: DMA拷贝,将数据从kernel buffer拷贝到protocol engine
        • 没有经过cpu拷贝,也就是操作系统级别的拷贝,实现了真正的零拷贝
    • 两次状态切换
      1. 第一次状态切换: 用户态 —> 内核态(或者叫着 用户上下文—-> 内核上下文)
      2. 第二次状态切换: 内核态—> 用户态

注:

  1. Linux2.4 提供的sendFile实现了真正的零拷贝
  2. 这里其实有 一次cpu 拷贝 kernel buffer -> socket buffer 但是,拷贝的信息很少,比如 lenght , offset , 消耗低,可以忽略

6、零拷贝的再次理解

  1. 我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)
  2. 零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

7、mmap与sendFile的区别

  1. mmap 适合小数据量读写,sendFile 适合大文件传输。
  2. mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
  3. sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。

8、NIO零拷贝案例

案例要求:

  1. 使用传统的IO 方法传递一个大文件
  2. 使用NIO 零拷贝方式传递(transferTo)一个大文件
  3. 看看两种传递方式耗时时间分别是多少

代码:

传统IO的服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

//java IO 的服务器
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(7001);

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

try {
byte[] byteArray = new byte[4096];

while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}

传统IO的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;

//java IO 的服务器
public class OldIOServer {

public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(7001);

while (true) {
Socket socket = serverSocket.accept();
DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

try {
byte[] byteArray = new byte[4096];

while (true) {
int readCount = dataInputStream.read(byteArray, 0, byteArray.length);

if (-1 == readCount) {
break;
}
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}

零拷贝的服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

//服务器
public class NewIOServer {
public static void main(String[] args) throws Exception {

InetSocketAddress address = new InetSocketAddress(7001);

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

ServerSocket serverSocket = serverSocketChannel.socket();

serverSocket.bind(address);

//创建buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();

int readcount = 0;
while (-1 != readcount) {
try {

readcount = socketChannel.read(byteBuffer);

}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒带 position = 0 mark 作废
}
}
}
}

零拷贝的客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.io.FileInputStream;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;

public class NewIOClient {
public static void main(String[] args) throws Exception {

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";

//得到一个文件channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();

//准备发送
long startTime = System.currentTimeMillis();

//在linux下一个transferTo 方法就可以完成传输
//在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要主要
//传输时的位置 =》 课后思考...
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() - startTime));

//关闭
fileChannel.close();

}
}

结果:

传统IO:

1
发送的总的字节数 = 1,007,473 耗时:60

零拷贝:

1
发送的总的字节数 = 1,007,473 耗时:21

4、Java AIO 以及 三种IO模型的对比

1、Java AIO 基本介绍

  1. JDK 7 引入了 Asynchronous I/O,即 AIO。在进行 I/O 编程中,常用到两种模式:ReactorProactorJava 的 NIO 就是 Reactor,当有事件触发时,服务器端得到通知,进行相应的处理
  2. AIO 即 NIO2.0,叫做异步不阻塞的 IO。AIO 引入异步通道的概念,采用了 Proactor 模式,简化了程序编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多且连接时间较长的应用
  3. 目前 AIO 还没有广泛应用,Netty 也是基于NIO, 而不是AIO, 因此我们就不详解AIO了,有兴趣的同学可以参考 <<Java新一代网络编程模型AIO原理及Linux系统AIO介绍>>

2、BIO、NIO、AIO对比表

BIO NIO AIO
IO 模型 同步阻塞 同步非阻塞(多路复用) 异步非阻塞
编程难度 简单 复杂 复杂
可靠性
可靠性

举例说明:

  1. 同步阻塞:到理发店理发,就一直等理发师,直到轮到自己理发。
  2. 同步非阻塞:到理发店理发,发现前面有其它人理发,给理发师说下,先干其他事情,一会过来看是否轮到自己。
  3. 异步非阻塞:给理发师打电话,让理发师上门服务,自己干其它事情,理发师自己来家给你理发

4、Netty概述

1、原生NIO存在的问题

  1. NIO 的类库和 API 繁杂,使用麻烦:需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
  2. 需要具备其他的额外技能:要熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网络编程非常熟悉,才能编写出高质量的 NIO 程序。
  3. 开发工作量和难度都非常大:例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常流的处理等等。
  4. JDK NIO 的 Bug:例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。直到 JDK 1.7 版本该问题仍旧存在,没有被根本解决。

2、Netty官网

Netty官网上的说明:

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients

image-20210818173529694

3、Netty官网说明

  • Netty 是由 JBOSS 提供的一个 Java 开源框架。Netty 提供==异步==的、==基于事件驱动==的网络应用程序框架,用以快速开发高性能、高可靠性的网络 IO 程序
  • Netty 可以帮助你快速、简单的开发出一个网络应用,相当于简化和流程化了 NIO 的开发过程
  • Netty 是目前最流行的 NIO 框架,Netty 在互联网领域、大数据分布式计算领域、游戏行业、通信行业等获得了广泛的应用,知名的 Elasticsearch 、Dubbo 框架内部都采用了 Netty。

4、Netty的优点

Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。

  1. 设计优雅:适用于各种传输类型的统一 API 阻塞和非阻塞 Socket;基于灵活且可扩展的事件模型,可以清晰地分离关注点;高度可定制的线程模型 - 单线程,一个或多个线程池。
  2. 使用方便:详细记录的 Javadoc,用户指南和示例;没有其他依赖项,JDK 5(Netty 3.x)或 6(Netty 4.x)就足够了。
  3. 高性能、吞吐量更高:延迟更低;减少资源消耗;最小化不必要的内存复制。
  4. 安全:完整的 SSL/TLS 和 StartTLS 支持。
  5. 社区活跃、不断更新:社区活跃,版本迭代周期短,发现的 Bug 可以被及时修复,同时,更多的新功能会被加入

5、Netty版本说明

  1. netty版本分为 netty3.x 和 netty4.x、netty5.x
  2. 因为Netty5出现重大bug,已经被官网废弃了,目前推荐使用的是Netty4.x的稳定版本
  3. 目前在官网可下载的版本 netty3.x netty4.0.x 和 netty4.1.x
  4. 本次以 Netty4.1.x 版本为主
  5. netty 下载地址

5、Netty 高性能架构设计

1、线程模型基本介绍

  1. 不同的线程模式,对程序的性能有很大影响,为了搞清Netty 线程模式,我们来系统的讲解下各个线程模式, 最后看看Netty 线程模型有什么优越性。
  2. 目前存在的线程模型有:
    • 传统阻塞 I/O 服务模型
    • Reactor 模式
  3. 根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现
    • 单 Reactor 单线程
    • 单 Reactor 多线程
    • 主从 Reactor 多线程
  4. Netty 线程模式(Netty 主要基于主从 Reactor 多线程模型做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor)

2、传统阻塞 I/O 服务模型

1、工作原理图

image-20210818180105435

黄色的框表示对象, 蓝色的框表示线程,白色的框表示方法(API)

2、模型特点

  1. 采用阻塞IO模式获取输入的数据
  2. 每个连接都需要独立的线程完成数据的输入,业务处理,数据返回

3、问题分析

  1. 当并发数很大,就会创建大量的线程,占用很大系统资源
  2. 连接创建后,如果当前线程暂时没有数据可读,该线程会阻塞在read 操作,造成线程资源浪费

3、Reactor 模式(整体)

1、针对传统阻塞 I/O 服务模型的 2 个缺点,解决方案

  1. ==基于 I/O 复用模型==:多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接。当某个连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理
    • `Reactor 对应的叫法:
      • 反应器模式
      • 分发者模式(Dispatcher)
      • 通知者模式(notifier)
  2. ==基于线程池复用线程资源==:不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以处理多个连接的业务。

image-20210818181829563

2、工作原理图

I/O 复用结合线程池,就是 Reactor 模式基本设计思想,如图

image-20210818181932485

3、模型特点

  1. Reactor 模式,通过一个或多个输入同时传递给服务处理器的模式(基于事件驱动)
  2. 服务器端程序处理传入的多个请求,并将它们同步分派到相应的处理线程, 因此Reactor模式也叫 Dispatcher模式
  3. Reactor 模式使用IO复用监听事件,收到事件后,分发给某个线程(进程),这点就是网络服务器高并发处理关键

4、Reactor 模式中 核心组成

  • ReactorReactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
  • Handlers处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

5、Reactor 模式分类

根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

  1. 单 Reactor 单线程
  2. 单 Reactor 多线程
  3. 主从 Reactor 多线程

4、单 Reactor 单线程

1、工作原理图

image-20210818182434248

2、原理图说明
  1. Select 是前面 I/O 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞对象监听多路连接请求
  2. Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发
  3. 如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理
  4. 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应
  5. Handler 会完成 Read→业务处理→Send 的完整业务流程

结合实例:服务器端用一个线程通过多路复用搞定所有的 IO 操作(包括连接,读、写等),编码简单,清晰明了,但是如果客户端连接数量较多,将无法支撑,前面的 NIO 案例就属于这种模型。

3、单 Reactor 单线程的优缺点
  • 优点:
    • 模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成
  • 缺点:
    • 性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈
    • 可靠性问题,线程意外终止,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障
  • 使用场景:
    • 客户端的数量有限,业务处理非常快速,比如 Redis在业务处理的时间复杂度 O(1) 的情况

5、单Reactor 多线程

1、工作原理图

image-20210818194738294

2、原理图说明

  1. Reactor 对象通过select 监控客户端请求事件,收到事件后,通过dispatch进行分发
  2. 如果建立连接请求,则由 Acceptor 通过accept 处理连接请求,然后创建一个Handler对象处理完成连接后的各种事件
  3. 如果不是连接请求,则由reactor分发调用连接对应的handler 来处理
  4. handler 只负责响应事件,不做具体的业务处理,通过read 读取数据后,会分发给后面的worker线程池的某个线程处理业务
  5. worker 线程池会分配独立线程完成真正的业务,并将结果返回给handler
  6. handler收到响应后,通过send 将结果返回给client

3、单Reactor 多线程的优缺点

  • 优点:可以充分的利用多核cpu 的处理能力
  • 缺点:多线程数据共享和访问比较复杂, reactor 处理所有的事件的监听和响应,在单线程运行, 在高并发场景容易出现性能瓶颈。

6、主从 Reactor 多线程

1、工作原理图

image-20210818195224698

针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,可以让 Reactor 在多线程中运行

2、原理图说明

  1. Reactor主线程 MainReactor 对象通过select 监听连接事件,收到事件后,通过Acceptor 处理连接事件
  2. 当 Acceptor 处理连接事件后,MainReactor 将连接分配给SubReactor
  3. subreactor 将连接加入到连接队列进行监听,并创建handler进行各种事件处理
  4. 当有新事件发生时, subreactor 就会调用对应的handler处理
  5. handler 通过read 读取数据,分发给后面的worker 线程处理
  6. worker 线程池分配独立的worker 线程进行业务处理,并返回结果
  7. handler 收到响应的结果后,再通过send 将结果返回给client
  8. Reactor 主线程可以对应多个Reactor 子线程,即MainRecator 可以关联多个SubReactor

3、Scalable IO in Java 对 Multiple Reactors 的原理图解

image-20210818195414795

4、主从 Reactor 多线程的优缺点

  • 优点:
    • 父线程与子线程的数据交互简单职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。
    • 父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传给子线程,子线程无需返回数据。
  • 缺点:
    • 编程复杂度较高
  • 结合实例:这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持

7、Reactor 模式小结

1、3 种模式用生活案例来理解

  1. 单 Reactor 单线程,前台接待员和服务员是同一个人,全程为顾客服务
  2. 单 Reactor 多线程,1 个前台接待员,多个服务员,接待员只负责接待
  3. 主从 Reactor 多线程,多个前台接待员,多个服务生

2、Reactor 模式具有如下的优点

  • 响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的
  • 可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销
  • 扩展性好,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源
  • 复用性好,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性

8、Netty模型

1、工作原理图1——简单版

Netty 主要基于主从 Reactors 多线程模型(如图)做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor。

image-20210818210506772

  1. BossGroup 线程维护 Selector ,只关注 Accecpt 事件
  2. 当接收到Accept事件,获取到对应的SocketChannel,封装成 NIOScoketChannel并注册到Worker 线程(事件循环),并进行维护
  3. 当Worker线程监听到 selector 中通道发生自己感兴趣的事件后,就进行处理(就由handler进行处理), 注意handler 已经加入到通道

2、工作原理图2——进阶版

image-20210818213124808

Netty 主要基于主从 Reactors 多线程模型(如图)做了一定的改进,其中主从 Reactor 多线程模型有多个 Reactor

3、工作原理图3——详细版

image-20210818213257551

4、原理图说明

  1. Netty抽象出两组线程池:
    • BossGroup:专门负责接收客户端的连接
    • WorkerGroup: 专门负责网络的读写
  2. BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup
  3. NioEventLoopGroup 相当于一个事件循环组, 这个组中含有多个事件循环 ,每一个事件循环是 NioEventLoop
  4. NioEventLoop 表示一个不断循环的执行处理任务的线程, 每个NioEventLoop 都有一个selector,用于监听绑定在其上的socket的网络通讯
  5. NioEventLoopGroup 可以有多个线程,即可以含有多个NioEventLoop
  6. 每个Boss NioEventLoop 循环执行的步骤有3步
    1. 轮询accept 事件
    2. 处理accept 事件,与client建立连接,生成NioScocketChannel,并将其注册到某个worker NIOEventLoop 上的 selector
    3. 处理任务队列的任务,即 runAllTasks
  7. 每个 Worker NIOEventLoop 循环执行的步骤
    1. 轮询read/write 事件
    2. 处理i/o事件,即read/write 事件,在对应NioScocketChannel上进行处理
    3. 处理任务队列的任务 , 即 runAllTasks
  8. 每个Worker NIOEventLoop 处理业务时,会使用pipeline(管道),pipeline 中包含了 channel,即通过pipeline 可以获取到对应通道,管道中维护了很多的处理器(可对数据进行相关的拦截与过滤等等)。

5、Netty快速入门实例——TCP服务

  1. 实例要求:使用IDEA 创建Netty项目
  2. Netty 服务器在 6668 端口监听,客户端能发送消息给服务器 “hello, 服务器~”
  3. 服务器可以回复消息给客户端 “hello, 客户端~”
  4. 目的:对Netty 线程模型 有一个初步认识,便于理解Netty 模型理论
    1. 编写服务端
    2. 编写客户端
    3. 对netty 程序进行分析,看看netty模型特点

代码:

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.awo.netty.simple;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class NettyServer {

public static void main(String[] args) {

EventLoopGroup bossGroup = null;
EventLoopGroup workerGroup = null;

try {
//创建BossGroup 和 WorkerGroup
//说明
//1. 创建两个线程组 bossGroup 和 workerGroup
//2. bossGroup 只是处理连接请求 , 真正的和客户端业务处理,会交给 workerGroup完成
//3. 两个都是无限循环
//4. bossGroup 和 workerGroup 含有的子线程(NioEventLoop)的个数
// 默认实际 cpu核数 * 2
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();

//创建服务器端的启动对象,配置参数
ServerBootstrap bootstrap = new ServerBootstrap();
//使用链式编程来进行设置
bootstrap.group(bossGroup, workerGroup) //设置两个线程组
.channel(NioServerSocketChannel.class) //使用NioSocketChannel 作为服务器的通道实现
.option(ChannelOption.SO_BACKLOG,128) // 设置线程队列得到连接个数
.childOption(ChannelOption.SO_KEEPALIVE,true) //设置保持活动连接状态
// .handler(null) // 该 handler对应 bossGroup , childHandler 对应 workerGroup
.childHandler(new ChannelInitializer<SocketChannel>() { //创建一个通道初始化对象(匿名对象)
//给pipeline 设置处理器
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//可以使用一个集合管理 SocketChannel, 再推送消息时,可以将业务加入到各个channel 对应的 NIOEventLoop 的 taskQueue 或者 scheduleTaskQueue
System.out.println("客户socketchannel hashcode=" + socketChannel.hashCode());
socketChannel.pipeline().addLast(new NettyServerHandler());
}
}); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器

System.out.println(".....服务器 is ready...");

//绑定一个端口并且同步, 生成了一个 ChannelFuture 对象
//启动服务器(并绑定端口)
ChannelFuture channelFuture = bootstrap.bind(6668).sync();

//给ChannelFuture注册监听器,监控我们关心的事件
channelFuture.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
if (channelFuture.isSuccess()) {
System.out.println("监听端口 6668 成功");
} else {
System.out.println("监听端口 6668 失败");
}
}
});

//对关闭通道进行监听
channelFuture.channel().closeFuture().sync();

} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
}
}
}

服务端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.awo.netty.simple;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

/**
* 说明:
* 1、我们自定义一个Handler 需要继承netty 规定好的某个HandlerAdapter(规范)
* 2、这时我们自定义一个Handler , 才能称为一个handler
*/
public class NettyServerHandler extends ChannelInboundHandlerAdapter {

/**
* 读取数据实际(这里我们可以读取客户端发送的消息)
* 1. ChannelHandlerContext ctx:上下文对象, 含有 管道pipeline , 通道channel, 地址
* 2. Object msg: 就是客户端发送的数据 默认Object
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//将 msg 转成一个 ByteBuf
//ByteBuf 是 Netty 提供的,不是 NIO 的 ByteBuffer.
ByteBuf byteBuf = (ByteBuf) msg;
System.out.println("客户端发送消息是:" + byteBuf.toString(CharsetUtil.UTF_8));
System.out.println("客户端地址:" + ctx.channel().remoteAddress());
}

/**
* 数据读取完毕
* @param ctx
* @throws Exception
*/
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
//writeAndFlush 是 write + flush
//将数据写入到缓存,并刷新
//一般讲,我们对这个发送的数据进行编码
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵",CharsetUtil.UTF_8));
}

/**
* 处理异常, 一般是需要关闭通道
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.awo.netty.simple;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

public class NettyClient {
public static void main(String[] args) {
//客户端需要一个事件循环组
EventLoopGroup group = new NioEventLoopGroup();
try {
//创建客户端启动对象
//注意客户端使用的不是 ServerBootstrap 而是 Bootstrap
Bootstrap bootstrap = new Bootstrap();
//设置相关参数
bootstrap.group(group) //设置线程组
.channel(NioSocketChannel.class) // 设置客户端通道的实现类(反射)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//加入自己的处理器
socketChannel.pipeline().addLast(new NettyClientHandler());
}
});

System.out.println("客户端 ok..");
//启动客户端去连接服务器端
//关于 ChannelFuture 要分析,涉及到netty的异步模型
ChannelFuture channelFuture = bootstrap.connect("127.0.0.1", 6668).sync();

//给关闭通道进行监听
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}
}

客户端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.awo.netty.simple;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.CharsetUtil;

public class NettyClientHandler extends ChannelInboundHandlerAdapter {
/**
* 当通道就绪就会触发该方法
*
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, server: (>^ω^<)喵", CharsetUtil.UTF_8));
}

/**
* 当通道有读取事件时,会触发
* @param ctx
* @param msg
* @throws Exception
*/
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
System.out.println("服务器回复的消息:" + buf.toString(CharsetUtil.UTF_8));
System.out.println("服务器的地址: "+ ctx.channel().remoteAddress());
}

/**
* 处理异常, 一般是需要关闭通道
* @param ctx
* @param cause
* @throws Exception
*/
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

6、相关问题以及解答

问题1:bossGroup与workerGroup含有的子线程的数量

解答:默认为CPU核数的两倍,即CPU核数*2

相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
// NioEventLoopGroup构造函数(空参)
public NioEventLoopGroup() {
this(0);
}
// 最终调用了其父类MultithreadEventLoopGroup的构造方法
protected MultithreadEventLoopGroup(int nThreads, Executor executor, Object... args) {
// 这里的nThread就是上面this的参数
super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, executor, args);
}
// DEFAULT_EVENT_LOOP_THREADS相关说明
// 其中的NettyRuntime.availableProcessors()返回的就是CPU核数,然后乘以2返回
private static final int DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));
问题2:Netty服务端接收的新连接是如何绑定到worker线程池的(即worker线程池是怎么分配线程的)?

解答:通过==轮询==的方式,(假设worker线程的个数为8)首先为第一个连接分配线程1,接着为第二个连接分配线程2……然后为第八个连接分配线程8;如果之后还有连接到来的的话,在线程1空闲的情况下,会分配线程1到第九个连接。

问题3:ctx(上下文对象)里面包含的内容

image-20210819024626333

ctx实际上是一个数据流,有着出站与入站(inbound 入站 ,outbound 出站)

问题4:channel与 pipeline 之间的关系

pipeline(管道) 本质上是一个双向链表,有着头尾指针。一个pipeline 与一个 channel对应,可以通过pipeline 获取到它对应的channel

image-20210819025333778

channel(通道) :其中也包含了与channel对应的pipeline对象

image-20210819025741518

7、任务队列中的 Task 有 3 种典型使用场景

如果当前有一个非常耗时长(长时间的操作)的业务,如果正常地放在handler中去执行的话,势必会造成pipeline的阻塞。因此,对于某些任务的执行可以提交到NioEventLoop的TaskQueue任务队列中去异步执行。其实TaskQueue与Channel之间存在绑定关系。对于这些任务有以下3种典型的应用:

  1. 用户程序自定义的普通任务
  2. 用户自定义定时任务
  3. 非当前 Reactor 线程调用 Channel 的各种方法

例如在推送系统的业务线程里面,根据用户的标识,找到对应的 Channel 引用,然后调用 Write 类方法向该用户推送消息,就会进入到这种场景。最终的 Write 会提交到任务队列中后被异步消费

将这些任务从handler中提交到channel对应的NIOEventLoop 的 TaskQueue的方法:

1、用户程序自定义的普通任务 -> 提交到该channel 对应的NioEventLoop 的 taskQueue中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 用户程序自定义的普通任务
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵2", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});

/*
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(20 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵3", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});
*/

注意:

  • 该方法是通过ctx获得channel对象,在通过channel对象去获取该channel所在的evevtLoop,最后在将任务提交到eventLoop的taskQueue中

  • eventLoop会起一个线程去异步解决taskQueue当中的任务,==注意是一个线程==。如果taskQueue当中有多个任务的话,那么该线程会按照taskQueue中任务的顺序依次执行任务,即执行taskQueue任务的时间是累加的

    • eg:taskQueue的第一个任务花费10s,taskQueue的第二个任务花费20s,那么该线程执行完taskQueue当中的任务总共要花费30s
  • 解决方法:

    1. 在当前Handler中创建一个业务线程池,把耗时任务放到创建的线程池中执行。此时就变成了一个线程有一个业务线程池,来完成耗时任务的异步操作。(局部异步)

      • 创建线程池的方法:

        • // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);
          
          // 调用一下方法将耗时任务放在线程池创建的线程中进行执行
          group.sumbit(Callable task);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          2. **在Server端中创建一个业务线程池(Context中添加线程池)**(整个异步)

          - 创建线程池的方法:

          - ```java
          // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);

          // 在ChannelInitializer的initChannel方法中
          ChannelPipeline p = chpipeline();
          // 在这里将group设置进去:如果这样设置的话,该handler会优先加入到该线程池中,这样一来,workerGroup主要接收任务 然后在将任务提交给线程池来处理。
          // 默认没添加group的话,handler会进入workerLoopGroup的某一个workerLoop子线程
          p.addLast(group,new MyServerHandler());
2、用户自定义定时任务 -> 提交到该channel 对应的NioEventLoop 的 scheduleTaskQueue中
1
2
3
4
5
6
7
8
9
10
11
12
13
ctx.channel().eventLoop().schedule(new Runnable() {
@Override
public void run() {

try {
Thread.sleep(5 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵4", CharsetUtil.UTF_8));
System.out.println("channel code=" + ctx.channel().hashCode());
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
}, 5, TimeUnit.SECONDS);

注意:

  • 该任务在5s后执行
  • sleep 需要占用线程资源,这5s线程啥都干不了,延时5s执行任务,这5s线程可以做别的事情
  • taskQueue里的任务执行完毕后,会再执行scheduledtaskQueue。并且scheduled里的延迟时间是从taskQueue执行第一个任务之前开始算的
  • 并且如果scheduled延迟时间若小于taskQueue里的总执行时间,在后者执行完后前者会立即执行,而不会在后者运行期间执行前者。
  • 以上代码只是一个延迟任务,如果是定时任务的话还少了个参数,在第一个数字(延迟时间)后加一个间隔时间
3、非当前 Reactor 线程调用 Channel 的各种方法
1
2
3
4
5
6
7
8
9
10
// 在Server端的ServerBootstrap的配置中的childHandler进行初始化的时候就可以将客户端的SocketChannel维护在一个集合里面,方便之后的获取
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道初始化对象(匿名对象)
//给pipeline 设置处理器
@Override
protected void initChannel(SocketChannel ch) throws Exception {
//可以使用一个集合管理 SocketChannel, 再推送消息时,可以将业务加入到各个channel 对应的 NIOEventLoop 的 taskQueue 或者 scheduleTaskQueue
System.out.println("客户socketchannel hashcode=" + ch.hashCode());
ch.pipeline().addLast(new NettyServerHandler());
}
}); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器

8、方案再说明

  1. Netty 抽象出两组线程池BossGroup 专门负责接收客户端连接WorkerGroup 专门负责网络读写操作
  2. NioEventLoop 表示一个不断循环执行处理任务的线程,每个 NioEventLoop 都有一个 selector,用于监听绑定在其上的 socket 网络通道
  3. NioEventLoop 内部采用==串行化设计==,从消息的读取->解码->处理->编码->发送,始终由 IO 线程 NioEventLoop 负责(如果在处理方面需要花费长时间的话就会阻塞这个流程,所以通常将花费长时间的任务放在taskQueue当中取异步执行)
    • NioEventLoopGroup 下包含多个 NioEventLoop
    • 每个 NioEventLoop 中包含有一个 Selector,一个 taskQueue
    • 每个 NioEventLoop 的 Selector 上可以注册监听多个 NioChannel
    • 每个 NioChannel 只会绑定在唯一的 NioEventLoop 上
    • 每个 NioChannel 都绑定有一个自己的 ChannelPipeline

6、异步模型

1、基本介绍

  1. 异步的概念和同步相对。当一个异步过程调用发出后,调用者不能立刻得到结果。实际处理这个调用的组件在完成后,通过状态、通知和回调来通知调用者。
  2. Netty 中的 I/O 操作是异步的,包括 Bind、Write、Connect 等操作会简单的返回一个 ChannelFuture。
  3. 调用者并不能立刻获得结果,而是通过 ==Future-Listener 机制==,用户可以方便的主动获取或者通过通知机制获得 IO 操作结果
  4. Netty 的异步模型是建立在 future 和 callback 的之上的。callback 就是回调。重点说 Future,它的核心思想是:假设一个方法 fun,计算过程可能非常耗时,等待 fun返回显然不合适。那么可以在调用 fun 的时候,立马返回一个 Future,后续可以通过 Future去监控方法 fun 的处理过程(即 : Future-Listener 机制)

2、Future说明

  1. 表示异步的执行结果,可以通过它提供的方法来检测执行是否完成,比如检索计算等等。

  2. ChannelFuture 是一个接口,我们可以添加监听器,当监听的事件发生时,就会通知到监听器。

    • public interface ChannelFuture extends Future<Void> {}
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40



      ### 3、工作原理图

      ![image-20210819212841178](Netty/image-20210819212841178.png)

      ![image-20210819212905885](Netty/image-20210819212905885.png)

      说明:

      1. 在使用 Netty 进行编程时,拦截操作和转换出入站数据只需要您提供 callback 或利用future 即可。这使得**链式操作**简单、高效, 并有利于编写可重用的、通用的代码。
      2. Netty 框架的目标就是让你的业务逻辑从网络基础应用编码中分离出来、解脱出来



      ### 4、Future-Listener 机制

      1. 当 Future 对象刚刚创建时,处于非完成状态,调用者可以通过返回的 ChannelFuture 来获取操作执行的状态,注册监听函数来执行完成后的操作。

      2. 常见有如下操作

      - 通过 `isDone` 方法来**判断当前操作是否完成**;
      - 通过 `isSuccess` 方法来**判断已完成的当前操作是否成功**;
      - 通过 `getCause` 方法来**获取已完成的当前操作失败的原因**;
      - 通过 `isCancelled` 方法来**判断已完成的当前操作是否被取消**;
      - 通过 `addListener` 方法来注册监听器,**当操作已完成(isDone 方法返回完成),将会通知指定的监听器;如果 Future 对象已完成,则通知指定的监听器**

      3. 举例说明

      - 演示:绑定端口是异步操作,当绑定操作处理完,将会调用相应的监听器处理逻辑

      - ```java
      serverBootstrap.bind(port).addListener(future -> {
      if(future.isSuccess()) {
      System.out.println(newDate() + ": 端口["+ port + "]绑定成功!");
      } else{
      System.err.println("端口["+ port + "]绑定失败!");
      }
      });

小结:相比传统阻塞 I/O,执行 I/O 操作后线程会被阻塞住,直到操作完成;异步处理的好处是不会造成线程阻塞,线程在 I/O 操作期间可以执行别的程序,在高并发情形下会更稳定和更高的吞吐量。

5、快速入门实例——HTTP服务

  1. 实例要求:使用IDEA 创建Netty项目
  2. Netty 服务器在 7777端口监听,浏览器发出请求 “http://localhost:7777/
  3. 服务器可以回复消息给客户端 “Hello! 我是服务器 5 “ ,并对特定请求资源进行过滤。
  4. 目的:Netty 可以做Http服务开发,并且理解Handler实例和客户端及其请求的关系。
  5. 效果:
    • image-20210819213544007

代码:

Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.awo.netty.http;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class TestServer {
public static void main(String[] args) {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).childHandler(new TestServerInitializer());
ChannelFuture channelFuture = serverBootstrap.bind(7777).sync();
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

TestServerInitializer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.awo.netty.http;

import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.http.HttpServerCodec;

public class TestServerInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//向管道加入处理器

//得到管道
ChannelPipeline pipeline = socketChannel.pipeline();
//加入一个netty 提供的httpServerCodec codec =>[coder - decoder]
//HttpServerCodec 说明
//1. HttpServerCodec 是netty 提供的处理http的 编-解码器
pipeline.addLast("MyHttpServerCodec", new HttpServerCodec());
//2. 增加一个自定义的handler
pipeline.addLast("MyTestHttpServerHandler", new TestHttpServerHandler());
System.out.println("ok~~~~");
}
}

TestHttpServerHandler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.awo.netty.http;


import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;

import java.net.URI;

/**
* 说明
* 1. SimpleChannelInboundHandler 是 ChannelInboundHandlerAdapter
* 2. HttpObject 客户端和服务器端相互通讯的数据被封装成 HttpObject
*/
public class TestHttpServerHandler extends SimpleChannelInboundHandler<HttpObject> {

/**
* channelRead0 读取客户端数据
*
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {

System.out.println("对应的channel=" + ctx.channel() + " pipeline=" + ctx
.pipeline() + " 通过pipeline获取channel" + ctx.pipeline().channel());

System.out.println("当前ctx的handler=" + ctx.handler());

//判断 msg 是不是 httprequest请求
if(msg instanceof HttpRequest) {

System.out.println("ctx 类型="+ctx.getClass());

System.out.println("pipeline hashcode" + ctx.pipeline().hashCode() + " TestHttpServerHandler hash=" + this.hashCode());

System.out.println("msg 类型=" + msg.getClass());
System.out.println("客户端地址" + ctx.channel().remoteAddress());

//获取到
HttpRequest httpRequest = (HttpRequest) msg;
//获取uri, 过滤指定的资源
URI uri = new URI(httpRequest.uri());
if("/favicon.ico".equals(uri.getPath())) {
System.out.println("请求了 favicon.ico, 不做响应");
return;
}
//回复信息给浏览器 [http协议]

ByteBuf content = Unpooled.copiedBuffer("hello, 我是服务器", CharsetUtil.UTF_8);

//构造一个http的相应,即 httpresponse
FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK, content);

response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json");
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());

//将构建好 response返回
ctx.writeAndFlush(response);
}
}
}

注意:

  • Http是无状态协议,而且建立的一般都是长链接,所以在刷新浏览器后,服务端会为本次的http请求创建新的handler和pipeline(一个handler与一个pipeline对应,为一组。多个http请求就会有多组handler与pipeline)

7、Netty 核心模块组件

1、Bootstrap、ServerBootstrap

  • Bootstrap 意思是引导,一个 Netty 应用通常由一个 Bootstrap 开始,主要作用是配置整个 Netty 程序,串联各个组件。
  • Netty 中 Bootstrap 类是==客户端==程序的启动引导类,ServerBootstrap 是==服务端==启动引导类

常见的方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 该方法用于服务器端,用来设置两个 EventLoop
public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup);

// 该方法用于客户端,用来设置一个 EventLoop
public B group(EventLoopGroup group);

// 该方法用来设置一个服务器端的通道实现
public B channel(Class<? extends C> channelClass);

// 用来给 ServerChannel 添加配置
public <T> B option(ChannelOption<T> option, T value);

// 用来给接收到的通道添加配置
public <T> ServerBootstrap childOption(ChannelOption<T> childOption, T value);

// 该方法用来设置业务处理类(自定义的 handler)
// handler对应 bossGroup , childHandler 对应 workerGroup
public ServerBootstrap childHandler(ChannelHandler childHandler);

// 该方法用于服务器端,用来设置占用的端口号
public ChannelFuture bind(int inetPort);

// 该方法用于客户端,用来连接服务器端
public ChannelFuture connect(String inetHost, int inetPort);

2、Channel

  1. Netty 网络通信的组件,能够用于执行网络 I/O 操作。
  2. 通过Channel 可获得 当前网络连接的通道的状态
  3. 通过Channel 可获得 网络连接的配置参数 (例如接收缓冲区大小)
  4. Channel 提供异步的网络 I/O 操作(如建立连接,读写,绑定端口),异步调用意味着任何 I/O 调用都将立即返回,并且不保证在调用结束时所请求的 I/O 操作已完成
  5. 调用立即返回一个 ChannelFuture 实例,通过注册监听器到 ChannelFuture 上,可以 I/O 操作成功、失败或取消时回调通知调用方
  6. 支持关联 I/O 操作与对应的处理程序
  7. 不同协议、不同的阻塞类型的连接都有不同的 Channel 类型与之对应,常用的 Channel 类型:
    • NioSocketChannel异步的客户端 TCP Socket 连接。
    • NioServerSocketChannel异步的服务器端 TCP Socket 连接。
    • NioDatagramChannel异步的 UDP 连接。
    • NioSctpChannel异步的客户端 Sctp 连接。
    • NioSctpServerChannel异步的 Sctp 服务器端连接,这些通道涵盖了 UDP 和 TCP 网络 IO 以及文件 IO。

3、Selector

  1. Netty 基于 Selector 对象实现 I/O 多路复用,通过 Selector 一个线程可以监听多个连接的 Channel 事件。
  2. 当向一个 Selector 中注册 Channel 后,Selector 内部的机制就可以自动不断地查询(Select) 这些注册的 Channel 是否有已就绪的 I/O 事件(例如可读,可写,网络连接完成等),这样程序就可以很简单地使用一个线程高效地管理多个 Channel

4、ChannelHandler 及其实现类

  1. ChannelHandler 是一个接口,处理 I/O 事件或拦截 I/O 操作,并将其转发到其 ChannelPipeline(业务处理链)中的下一个处理程序。
  2. ChannelHandler 本身并没有提供很多方法,因为这个接口有许多的方法需要实现,方便使用期间,可以继承它的子类

ChannelHandler 及其实现类一览图:

image-20210819233354647

  • ChannelInboundHandler:用于处理入站 I/O 事件。
  • ChannelOutboundHandler:用于处理出站 I/O 操作。

适配器模式:

  • ChannelInboundHandlerAdapter:用于处理入站 I/O 事件。
  • ChannelOutboundHandlerAdapter:用于处理出站 I/O 操作。
  • ChannelDuplexHandler:用于处理入站和出站事件。

为什么ChannelDuplexHandler既能解决入站事件,又能解决出站事件?

查看ChannelDuplexHandler的实现

1
public class ChannelDuplexHandler extends ChannelInboundHandlerAdapter implements ChannelOutboundHandler {...}
  • ChannelDuplexHandler继承了ChannelInboundHandlerAdapter类,所以能解决入站事件
  • ChannelDuplexHandler实现了ChannelOutboundHandler接口,所以能解决出站事件

我们经常需要自定义一个 Handler 类去继承 ChannelInboundHandlerAdapter,然后通过重写相应方法实现业务逻辑,我们接下来看看一般都需要重写哪些方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ChannelInboundHandlerAdapter extends ChannelHandlerAdapter implements ChannelInboundHandler {
public ChannelInboundHandlerAdapter() {}
public void channelRegistered(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelRegistered();
}
public void channelUnregistered(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelUnregistered();
}
// 通道就绪事件
public void channelActive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelActive();
}
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelInactive();
}
// 通道读取数据事件
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
ctx.fireChannelRead(msg);
}
// 数据读取完毕事件
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelReadComplete();
}
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
ctx.fireUserEventTriggered(evt);}
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
ctx.fireChannelWritabilityChanged();
}
// 通道发生异常事件
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.fireExceptionCaught(cause);
}
}

ChannelInboundHandlerAdapter的所有实现方法:

image-20210819234347536

image-20210819234419147

5、Pipeline 和 ChannelPipeline

ChannelPipeline 是一个重点:

  1. ChannelPipeline 是一个 Handler 的集合,它负责处理和拦截 inbound 或者 outbound 的事件和操作,相当于一个贯穿 Netty 的链。(也可以这样理解:ChannelPipeline 是 保存 ChannelHandler 的 List,用于处理或拦截 Channel 的入站事件和出站操作)

  2. ChannelPipeline 实现了一种高级形式的拦截过滤器模式,使用户可以完全控制事件的处理方式,以及 Channel 中各个的 ChannelHandler 如何相互交互

  3. 在 Netty 中每个 Channel 都有且仅有一个 ChannelPipeline 与之对应,它们的组成关系如下

    • image-20210819234704352

    • 一个 Channel 包含了一个 ChannelPipeline,而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表,并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler

      • 可以通过Channel拿到ChannelPipeline,也可以通过ChannelPipeline拿到Channel(双方都包含对方的引用)

      • ChannelHandlerContext实际上是一个接口,在双向链表当中的ChannelHandlerContext实际上是ChannelHandlerContext的实现类 DefaultChannelHandlerContext

        • public interface ChannelHandlerContext extends AttributeMap, ChannelInboundInvoker, ChannelOutboundInvoker {...}
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16

          - 入站事件和出站事件在一个双向链表中,入站事件会从链表 head 往后传递到最后一个入站的 handler,出站事件会从链表 tail 往前传递到最前一个出站的 handler,两种类型的 handler 互不干扰

          - ChannelPipeline提供了ChannelHandler链的容器。以==客户端==应用程序为例:
          - **如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的**,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler,并被这些Handler处理,以上图就是从链表 tail 往前传递到最前一个出站的 handler (head)
          - **如果事件的运动方向是从服务端到客户端的,那么我们称这些事件为入站的**,即服务端发送给客户端的数据会通过pipeline中的一系列ChannelInboundHandler,并被这些Handler处理,以上图就是从链表 head 往后传递到最后一个入站的 handler(tail)。
          - 前面客户端和服务端都是Inbound是因为他们都要读对方的消息,读取对方的消息就是入站

          4. 常用方法

          - ```java
          // 把一个业务处理类(handler)添加到链中的第一个位置
          ChannelPipeline addFirst(ChannelHandler... handlers);

          // 把一个业务处理类(handler)添加到链中的最后一个位置
          ChannelPipeline addLast(ChannelHandler... handlers);

6、ChannelHandlerContext

  1. 保存 Channel 相关的所有上下文信息,同时关联一个 ChannelHandler 对象

  2. 即ChannelHandlerContext 中包含一个具体的事件处理器 ChannelHandler , 同时ChannelHandlerContext 中也绑定了对应的 pipeline 和 Channel 的信息,方便对 ChannelHandler进行调用。

  3. 常用方法

    • // 关闭通道
      ChannelFuture close();
      
      // 刷新
      ChannelOutboundInvoker flush();
      
      // 将数据写到 ChannelPipeline 中当前ChannelHandler 的下一个 ChannelHandler 开始处理(出站)
      ChannelFuture writeAndFlush(Object msg);
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33



      ### 7、ChannelOption

      1. Netty 在创建 Channel 实例后,一般都需要设置 ChannelOption 参数。
      2. ChannelOption 参数如下:
      - `ChannelOption.SO_BACKLOG`:对应 TCP/IP 协议 listen 函数中的 backlog 参数,**用来初始化服务器可连接队列大小**。服务端处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接。多个客户端来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理,backlog 参数指定了队列的大小。
      - `ChannelOption.SO_KEEPALIVE`:一直保持连接活动状态



      ### 8、EventLoopGroup 和其实现类 NioEventLoopGroup

      1. EventLoopGroup 是一组 EventLoop 的抽象,Netty 为了更好的利用多核 CPU 资源,一般会有多个 EventLoop 同时工作,每个 EventLoop 维护着一个 Selector 实例。

      2. EventLoopGroup 提供 next 接口,可以从组里面按照一定规则获取其中一个 EventLoop来处理任务。在 Netty ==服务器端==编程中,我们一般都需要提供两个 EventLoopGroup,例如:`BossEventLoopGroup` 和 `WorkerEventLoopGroup`。

      3. 通常一个服务端口,即一个 ServerSocketChannel 对应一个Selector 和一个EventLoop线程。BossEventLoop 负责接收客户端的连接并将 SocketChannel 交给 WorkerEventLoopGroup 来进行 IO 处理,如下图所示

      - ![image-20210820000928747](Netty/image-20210820000928747.png)
      - BossEventLoopGroup 通常是一个单线程的 EventLoop,EventLoop 维护着一个注册了ServerSocketChannel 的 Selector 实例,BossEventLoop 不断轮询 Selector 将连接事件分离出来
      - 通常是 `OP_ACCEPT` 事件,然后将接收到的 SocketChannel 交给 WorkerEventLoopGroup
      - WorkerEventLoopGroup 会由 next 选择其中一个 EventLoop来将这个 SocketChannel 注册到其维护的 Selector 并对其后续的 IO 事件进行处理

      4. 常用方法

      - ```java
      // 构造方法
      public NioEventLoopGroup();

      // 断开连接,关闭线程
      public Future<?> shutdownGracefully();

9、Unpooled 类

  1. Netty 提供一个专门用来操作缓冲区(即Netty的数据容器)的工具类

  2. 常用方法如下所示

    • //通过给定的数据和字符编码返回一个 ByteBuf 对象(类似于 NIO 中的 ByteBuffer 但有区别)
      public static ByteBuf copiedBuffer(CharSequence string, Charset charset)
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19

      ![image-20210820003558340](Netty/image-20210820003558340.png)

      ```java
      // 创建一个ByteBuf
      ByteBuf buffer = Unpooled.buffer(10);

      // 常用方法
      // 写入
      buffer.writeByte(i);

      // 读取
      buffer.readByte();

      // 根据下标读取
      buffer.getByte(i);

      // 获取buffer的长度
      buffer.capacity();

结合上图与代码对ByteBuf进行讲解:

  • 创建一个ByteBuf对象,该对象包含一个数组arr , 是一个byte[10]
  • 在netty 的ByteBuf中,不需要先nio中的ByteBuffer一样,使用flip 进行读写反转
    • 原因:netty 的ByteBuf在底层维护了两个变量:readerindexwriterIndex(双指针模式)。其中
      • readerindex:用于记录ByteBuf读时的位置
      • writerIndex:用于记录ByteBuf写时的位置
    • netty 的ByteBuf在底层还维护了一个重要的变量:capacity——用来保存ByteBuf的底层byte[]数组的长度
    • 通过这3个变量的合作,完成了netty的ByteBuf的读写相关操作
  • 通过 readerindex 和 writerIndex 和 capacity, 将buffer分成三个区域(从上图可以看出)
    • [0,readerindex):已经读取的区域
    • [readerindex,writerIndex):可读的区域
    • [writerIndex,capacity):可写的区域
  • 在ByteBuf读取的方法中,有着 getByte(int) 与 readByte()两个方法,两者的区别:
    • 对于readByte()方法:readerindex会随着readByte()方法的执行而增加
    • 对于getByte(int)方法:readerindex不会随着readByte()方法的执行而增加
  • 对于ByteBuf的写方法:writeByte()——writerIndex会随着writeByte()方法的执行而增加
  • 注意:
    • 如果使用了ByteBuf的readByte()方法进行读取的时候,由于readerindex会随着readByte()方法的执行而增加,所以在进行第二次读取的时候会发生数组下标越界异常,需要我们调用ByteBuf的readerIndex(int readIndex)方法重新设置读取位置。

也可以通过以下方法创建一个ByteBuf对象:

1
2
//创建ByteBuf
ByteBuf byteBuf = Unpooled.copiedBuffer("hello,world!", Charset.forName("utf-8"));

ByteBuf的一些API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 查看当前的ByteBuf是否有数组支撑
byteBuf.hasArray();

// 将当前ByteBuf转成char[]数组
byte[] content = byteBuf.array();

// 将 content 转成字符串
new String(content, Charset.forName("utf-8"));

// 获取ByteBuf的偏移量
byteBuf.arrayOffset(); // 0

// 获取ByteBuf的readerindex
byteBuf.readerIndex(); // 0

// 获取ByteBuf的writerIndex
byteBuf.writerIndex(); // 12

// 获取ByteBuf的容量
byteBuf.capacity(); // 36

// 获取ByteBuf的可读的字节数,根据readerIndex推出来的
// 如果在这之前调用了ByteBuf的readByte()方法,则byteBuf.readableBytes();返回的值为11
byteBuf.readableBytes(); // 12

// 按照某个范围读取,其中第一个参数:从哪里开始;第二个参数:读取的长度;第三参数:格式
byteBuf.getCharSequence(0, 4, Charset.forName("utf-8"));

注意:通过这种方式创建出来的bytebuf对象的底层实际上是UnpooledByteBufAllocator的内部类InstrumentedUnpooledUnsafeHeapByteBuf类型。

image-20210820035309447

10、Netty应用实例-群聊系统

实例要求:

  1. 编写一个 Netty 群聊系统,实现服务器端和客户端之间的数据简单通讯(非阻塞)
  2. 实现多人群聊
  3. 服务器端:可以监测用户上线,离线,并实现消息转发功能
  4. 客户端:通过channel 可以无阻塞发送消息给其它所有用户,同时可以接受其它用户发送的消息(有服务器转发得到)
  5. 目的:进一步理解Netty非阻塞网络编程机制
  6. 效果:
    • image-20210820001615735

代码:

服务器端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.awo.netty.groupchat;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

public class GroupChatServer {

private final int port;

public GroupChatServer(int port) {
this.port = port;
}

/**
* 编写run方法,处理客户端的请求
*/
public void run() {
//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {
ServerBootstrap serverBootstrap = new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG,128)
.childOption(ChannelOption.SO_KEEPALIVE,true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//获取到pipeline
ChannelPipeline pipeline = socketChannel.pipeline();
//向pipeline加入解码器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入编码器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的业务处理handler
pipeline.addLast(new GroupChatServerHandler());
}
});
System.out.println("netty 服务器启动");
ChannelFuture future = serverBootstrap.bind(port).sync();
//监听关闭
future.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}

public static void main(String[] args) {
new GroupChatServer(7777).run();
}
}

服务器端的handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.awo.netty.groupchat;

import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.util.concurrent.GlobalEventExecutor;

import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;

public class GroupChatServerHandler extends SimpleChannelInboundHandler<String> {

//定义一个channle 组,管理所有的channel
//GlobalEventExecutor.INSTANCE) 是全局的事件执行器,是一个单例
private static ChannelGroup channelGroup = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

/**
* handlerAdded 表示连接建立,一旦连接,第一个被执行
* 将当前channel 加入到 channelGroup
* @param ctx
* @throws Exception
*/
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
//将该客户加入聊天的信息推送给其它在线的客户端
/*
该方法会将 channelGroup 中所有的channel 遍历,并发送 消息,
我们不需要自己遍历
*/
channel.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 加入聊天" + " \n");
channelGroup.add(channel);
}

/**
* 断开连接, 将xx客户离开信息推送给当前在线的客户
* @param ctx
* @throws Exception
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
channel.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 离开了" + " \n");
}

/**
* 表示channel 处于活动状态, 提示 xx上线
* @param ctx
* @throws Exception
*/
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
System.out.println("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 上线了~");
}

/**
* 表示channel 处于不活动状态, 提示 xx离线了
* @param ctx
* @throws Exception
*/
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
System.out.println("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 离线了~");
}

/**
* 读取数据
* @param ctx
* @param msg
* @throws Exception
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
//获取到当前channel
Channel channel = ctx.channel();
//这时我们遍历channelGroup, 根据不同的情况,回送不同的消息
channelGroup.forEach(ch -> {
if (ch != channel) {
ch.writeAndFlush("[客户端 " + sdf.format(new Date()) + "]" + channel.remoteAddress() + " 发送了消息" + msg + "\n");
} else { //回显自己发送的消息给自己
channel.writeAndFlush("[自己 " + sdf.format(new Date()) + "]" + msg + "\n");
}
});
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//关闭通道
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.awo.netty.groupchat;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

import java.util.Scanner;

public class GroupChatClient {
//属性
private final String host;
private final int port;

public GroupChatClient(String host, int port) {
this.host = host;
this.port = port;
}

public void run() {
EventLoopGroup group = new NioEventLoopGroup();

try {
Bootstrap bootstrap = new Bootstrap()
.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
//获取到pipeline
ChannelPipeline pipeline = socketChannel.pipeline();
//向pipeline加入解码器
pipeline.addLast("decoder", new StringDecoder());
//向pipeline加入编码器
pipeline.addLast("encoder", new StringEncoder());
//加入自己的业务处理handler
pipeline.addLast(new GroupChatClientHandler());
}
});

ChannelFuture channelFuture = bootstrap.connect(host, port).sync();
//得到channel
Channel channel = channelFuture.channel();
System.out.println("-------" + channel.localAddress()+ "--------");
//客户端需要输入信息,创建一个扫描器
Scanner sc = new Scanner(System.in);
while (sc.hasNextLine()) {
String msg = null;
msg = sc.nextLine();
//通过channel 发送到服务器端
channel.writeAndFlush(msg + "\r\n");
}
//channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
group.shutdownGracefully();
}
}

public static void main(String[] args) {
new GroupChatClient("localhost", 7777).run();
}
}

客户端的handler:

1
2
3
4
5
6
7
8
9
10
11
package com.awo.netty.groupchat;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class GroupChatClientHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, String msg) throws Exception {
System.out.println(msg.trim());
}
}

sync()是因为:本身bootstrap里的任务如:监听器等等是异步的。所以适用此方法等待异步方法处理完毕再完成启动

11、Netty心跳检测机制案例

实例要求:

  1. 编写一个 Netty心跳检测机制案例, 当服务器超过3秒没有读时,就提示读空闲
  2. 当服务器超过5秒没有写操作时,就提示写空闲
  3. 实现当服务器超过7秒没有读或者写操作时,就提示读写空闲

代码:

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.atguigu.netty.heartbeat;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.timeout.IdleStateHandler;

import java.util.concurrent.TimeUnit;

public class MyServer {
public static void main(String[] args) throws Exception{


//创建两个线程组
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); //8个NioEventLoop
try {

ServerBootstrap serverBootstrap = new ServerBootstrap();

serverBootstrap.group(bossGroup, workerGroup);
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.handler(new LoggingHandler(LogLevel.INFO));
serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
//加入一个netty 提供 IdleStateHandler
pipeline.addLast(new IdleStateHandler(7000,7000,10, TimeUnit.SECONDS));
//加入一个对空闲检测进一步处理的handler(自定义)
pipeline.addLast(new MyServerHandler());
}
});

//启动服务器
ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}

服务器的handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.atguigu.netty.heartbeat;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.timeout.IdleStateEvent;

public class MyServerHandler extends ChannelInboundHandlerAdapter {

/**
*
* @param ctx 上下文
* @param evt 事件
* @throws Exception
*/
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {

if(evt instanceof IdleStateEvent) {

//将 evt 向下转型 IdleStateEvent
IdleStateEvent event = (IdleStateEvent) evt;
String eventType = null;
switch (event.state()) {
case READER_IDLE:
eventType = "读空闲";
break;
case WRITER_IDLE:
eventType = "写空闲";
break;
case ALL_IDLE:
eventType = "读写空闲";
break;
}
System.out.println(ctx.channel().remoteAddress() + "--超时时间--" + eventType);
System.out.println("服务器做相应处理..");

//如果发生空闲,我们关闭通道
// ctx.channel().close();
}
}
}

对于以上代码的几点说明:

  • **handler(new LoggingHandler(LogLevel.INFO));**:这代码的作用是在bossGroup开启日志处理

  • IdleStateHandler类的相关说明:

    • IdleStateHandler 是netty 提供的处理空闲状态的处理器

    • 文档说明:==triggers an {@link IdleStateEvent} when a {@link Channel} has not performed read, write, or both operation for a while.==

    • public class IdleStateHandler extends ChannelDuplexHandler {
      
          public IdleStateHandler(long readerIdleTime, long writerIdleTime, long allIdleTime, TimeUnit unit) {
              this(false, readerIdleTime, writerIdleTime, allIdleTime, unit);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61

      - IdleStateHandler继承了ChannelDuplexHandler,说明了它既能处理入站事件,也能处理出站事件

      - IdleStateHandler的构造方法的参数说明:

      1. **long readerIdleTime**:表示多长时间没有读,就会发送一个心跳检测包检测是否连接
      2. **long writerIdleTime**:表示多长时间没有写,就会发送一个心跳检测包检测是否连接
      3. **long allIdleTime**:表示多长时间没有读写,就会发送一个心跳检测包检测是否连接
      4. **TimeUnit unit**:时间单位

      - 当 IdleStateEvent 触发后,就会传递给管道 的下一个handler去处理
      * 通过调用(触发)下一个handler 的 `userEventTiggered`方法,在该方法中去处理 IdleStateEvent(读空闲,写空闲,读写空闲)



      ### 12、Netty 通过WebSocket编程实现服务器和客户端长连接

      实例要求:

      1. Http协议是无状态的, 浏览器和服务器间的请求响应一次,下一次会重新创建连接.
      2. 要求:实现基于webSocket的长连接的全双工的交互
      3. 改变Http协议多次请求的约束,实现长连接了, 服务器可以发送消息给浏览器
      4. 客户端浏览器和服务器端会相互感知,比如服务器关闭了,浏览器会感知,同样浏览器关闭了,服务器会感知
      5. 效果:
      - ![image-20210820001859808](Netty/image-20210820001859808.png)

      代码:

      服务器端:(ChannelInitializer<SocketChannel>当中的内容,其他与上面类似)

      ```java
      serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {

      @Override
      protected void initChannel(SocketChannel ch) throws Exception {
      ChannelPipeline pipeline = ch.pipeline();

      //因为基于http协议,使用http的编码和解码器
      pipeline.addLast(new HttpServerCodec());
      //是以块方式写,添加ChunkedWriteHandler处理器
      pipeline.addLast(new ChunkedWriteHandler());

      /*
      说明
      1. http数据在传输过程中是分段, HttpObjectAggregator ,就是可以将多个段聚合
      2. 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
      */
      pipeline.addLast(new HttpObjectAggregator(8192));
      /*
      说明
      1. 对应websocket ,它的数据是以 帧(frame) 形式传递
      2. 可以看到WebSocketFrame 下面有六个子类
      3. 浏览器请求时 ws://localhost:7000/hello 表示请求的uri
      4. WebSocketServerProtocolHandler 核心功能是将 http协议升级为 ws协议 , 保持长连接
      */
      pipeline.addLast(new WebSocketServerProtocolHandler("/hello2"));

      //自定义的handler ,处理业务逻辑
      pipeline.addLast(new MyTextWebSocketFrameHandler());
      }
      });

服务器端的Handler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.atguigu.netty.websocket;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;

import java.time.LocalDateTime;

//这里 TextWebSocketFrame 类型,表示一个文本帧(frame)
public class MyTextWebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame>{
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {

System.out.println("服务器收到消息 " + msg.text());

//回复消息
ctx.channel().writeAndFlush(new TextWebSocketFrame("服务器时间" + LocalDateTime.now() + " " + msg.text()));
}

//当web客户端连接后, 触发方法
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
//id 表示唯一的值,LongText 是唯一的 ShortText 不是唯一
System.out.println("handlerAdded 被调用" + ctx.channel().id().asLongText());
System.out.println("handlerAdded 被调用" + ctx.channel().id().asShortText());
}


@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {

System.out.println("handlerRemoved 被调用" + ctx.channel().id().asLongText());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常发生 " + cause.getMessage());
ctx.close(); //关闭连接
}
}

对以上代码的几点说明:

  • 由于建立的是webSocket长连接,http为短连接,需要将http协议升级为ws协议
  • 对于http:
    • http数据在传输过程中是分段传输,所以需要添加HttpObjectAggregator ,可以将多个段的数据进行聚合
    • 这就就是为什么,当浏览器发送大量数据时,就会发出多次http请求
  • 对于webSocket:
    • webSocket的数据是以 帧(frame) 形式传递,所以在进行数据处理的时候都是以帧为单位进行处理的
    • 对于ws协议:
      • 浏览器请求时 ws://localhost:7000/xxx:表示请求的uri
      • http协议升级为ws协议的方法:是通过一个 状态码 101
        • image-20210821000047461
    • netty与ws协议的一些方法:
      • webSocket数据对应类:WebSocketFrame,其下有六个子类,分别应用在不同的场景
        • image-20210820233534694
      • WebSocketServerProtocolHandler:核心功能是将 http协议升级为 ws协议,保持长连接

8、Google Protobuf

1、编码和解码的基本介绍

  1. 编写网络应用程序时,因为数据在网络中传输的都是二进制字节码数据,在发送数据时就需要编码,接收数据时就需要解码
    • image-20210821000350763
  2. codec(编解码器) 的组成部分有两个:decoder(解码器)encoder(编码器)
    • encoder:负责把业务数据转换成字节码数据
    • decoder:负责把字节码数据转换成业务数据

2、Netty 本身的编码解码的机制和问题分析

  1. Netty 自身提供了一些 codec(编解码器)
  2. Netty 提供的编码器encoder
    • StringEncoder:对字符串数据进行编码
    • ObjectEncoder:对 Java 对象进行编码
    • ……
  3. Netty 提供的解码器decoder
    • StringDecoder:对字符串数据进行解码
    • ObjectDecoder:对 Java 对象进行解码
    • ……
  4. Netty 本身自带的 ObjectDecoder 和 ObjectEncoder 可以用来实现 POJO 对象或各种业务对象的编码和解码,底层使用的仍是 Java 序列化技术,而Java 序列化技术本身效率就不高,存在如下问题:
    • 无法跨语言
    • 序列化后的体积太大,是二进制编码的 5 倍多。
    • 序列化性能太低
    • => 引出 新的解决方案 [Google 的 Protobuf]

3、Protobuf

1、Protobuf基本介绍和使用示意图

  1. Protobuf 是 Google 发布的开源项目,全称 Google Protocol Buffers,是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,或者说序列化。它很适合做数据存储或 RPC[远程过程调用 remote procedure call ] 数据交换格式 。
    目前很多公司将http+json 替换成 tcp+protobuf
  2. 参考文档 :语言指南
  3. Protobuf 是以 message 的方式来管理数据的
  4. 支持跨平台跨语言,即[客户端和服务器端可以是不同的语言编写的] (支持目前绝大多数语言,例如 C++、C#、Java、python 等)
  5. 高性能,高可靠性
  6. 使用 protobuf 编译器能自动生成代码,Protobuf 是将类的定义使用.proto 文件进行描述
    • 说明,在idea 中编写 .proto 文件时,会自动提示是否下载 .ptotot 编写插件. 可以让语法高亮
  7. 然后通过 protoc.exe 编译器根据.proto 自动生成.java 文件
  8. protobuf 使用示意图:
    • image-20210821023654601

2、Netty中Protobuf的使用流程

  1. 在Maven 项目中引入 Protobuf 坐标,下载相关的jar包

    • <dependencies>
          <dependency>
              <groupId>com.google.protobuf</groupId>
              <artifactId>protobuf-java</artifactId>
              <version>3.6.1</version>
          </dependency>
      </dependencies>
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      2. 在IDEA创建.proto文件,进行.proto文件的编写(以Student.proto为例)

      - ```protobuf
      syntax = "proto3"; //版本
      option java_outer_classname = "StudentPOJO";//生成的外部类名,同时也是文件名
      //protobuf 使用message 管理数据
      message Student { //会在 StudentPOJO 外部类生成一个内部类 Student, 他是真正发送的POJO对象
      int32 id = 1; // Student 类中有一个属性 名字为 id 类型为int32(protobuf类型) 1表示属性序号,不是值
      string name = 2;
      }
  2. 利用protoc.exe 编译器对刚刚编写好的.proto文件进行编译,生成一个java文件

    • 执行指令(cmd)

      • protoc.exe --java_out=. Student.proto
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10

        - idea里面也可以下载相应的maven插件进行编译:有工具mave protobuf-java-util

        4. 之后会生成一个Student.java文件

        - 这里主要是看两点:

        - ```java
        // DO NOT EDIT!
        public static final class Student extends com.google.protobuf.GeneratedMessageV3 implements // 说明真正的PoJo 类是Student
  3. 把生成的 StudentPoJo.java 拷贝到自己的项目中打开

  4. 在项目的服务端ChannelInitializer<SocketChannel>中的initChannel方法里面添加解码的handler(服务端<—>解码),在解码的handler中添加StudentPOJO.Student.getDefaultInstance()

    • serverBootstrap.childHandler(new ChannelInitializer<SocketChannel>() {//创建一个通道初始化对象(匿名对象)
          //给pipeline 设置处理器
          @Override
          protected void initChannel(SocketChannel ch) throws Exception {
              ChannelPipeline pipeline = ch.pipeline();
              //在pipeline加入ProtoBufDecoder
              //指定对哪种对象进行解码
              pipeline.addLast("decoder", new ProtobufDecoder(StudentPOJO.Student.getDefaultInstance()));
              pipeline.addLast(new NettyServerHandler());
          }
      }); // 给我们的workerGroup 的 EventLoop 对应的管道设置处理器
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      7. 在项目的客户端`ChannelInitializer<SocketChannel>`中的`initChannel`方法里面添加编码的handler(客户端<--->编码)

      - ```java
      bootstrap.handler(new ChannelInitializer<SocketChannel>() {
      @Override
      protected void initChannel(SocketChannel ch) throws Exception {
      ChannelPipeline pipeline = ch.pipeline();
      //在pipeline中加入 ProtoBufEncoder
      pipeline.addLast("encoder", new ProtobufEncoder());
      pipeline.addLast(new NettyClientHandler()); //加入自己的处理器
      }
      });
  5. 在服务端的自定义handler中可以选择继承SimpleChannelInboundHandler并设置泛型StudentPOJO.Student,这样一来重写的channelRead0方法的第二个参数就变成了StudentPOJO.Student msg(而不是Object,还需要我们去判断Object类型向下转型),我可以通过该msg获取Student的相关信息

  6. 而在客户端就需要我们去生成一个StudentPOJO.Student,往StudentPOJO.Student设置一些信息:

    • StudentPOJO.Student student = StudentPOJO.Student.newBuilder().setId(4).setName("智多星 吴用").build();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55



      #### 3、使用Protobuf的几点说明

      1. Protobuf是以`message`的方式来管理数据的

      2. 在.proto文件的编写中使用message声明的变量,在之后生成java文件后会成为java文件的内部类,也是真正存储PoJo 类信息的地方,使用option java_outer_classname方式生成的类对象其实是java的外部类,包裹着存储PoJo 类信息的内部类

      3. java PoJo 类的属性数据类型 与 Protobuf文件中的属性数据类型的对比:

      ![image-20210821025959285](Netty/image-20210821025959285.png)

      4. 在Protobuf文件中 `int32 id = 1`中的`1`并不是属性的值,而是该属性在Protobuf文件的属性序号,即该属性是Protobuf文件的第几个属性(**从1开始**)

      5. 通过以上的项目的服务端与客户端可以发现一个问题:项目的handler与PoJo的耦合很高。基本上一个handler只能为一个PoJo服务

      6. 解决方法:**Protobuf可以使用 message 管理其他的message**

      7. Protobuf文件中可以使用一个总的message作为大包裹,里面包含了各式各样的PoJo信息——使用枚举的方式(注意:**在proto3 要求enum的编号从0开始**)

      - ```protobuf
      syntax = "proto3";
      option optimize_for = SPEED; // 加快解析
      option java_package="com.atguigu.netty.codec2"; //指定生成到哪个包下
      option java_outer_classname="MyDataInfo"; // 外部类名, 文件名

      //protobuf 可以使用message 管理其他的message
      message MyMessage {

      //定义一个枚举类型
      enum DataType {
      StudentType = 0; //在proto3 要求enum的编号从0开始
      WorkerType = 1;
      }

      //用data_type 来标识传的是哪一个枚举类型
      DataType data_type = 1;

      //表示每次枚举类型最多只能出现其中的一个, 节省空间
      oneof dataBody {
      Student student = 2;
      Worker worker = 3;
      }

      }

      message Student {
      int32 id = 1;//Student类的属性
      string name = 2; //
      }
      message Worker {
      string name=1;
      int32 age=2;
      }
  7. 这样的话,在服务端的ChannelInitializer<SocketChannel>中的initChannel方法的里面ProtobufDecoder的里面就不能写某个PoJo的getDefaultInstance(),而是得写整个大包裹的getDefaultInstance()

    • pipeline.addLast("decoder", new ProtobufDecoder(MyDataInfo.MyMessage.getDefaultInstance()));
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      9. 各种PoJo的信息的设置与获取在各自的handler中

      - 设置(客户端)

      - ```java
      MyDataInfo.MyMessage myMessage = null;
      myMessage = MyDataInfo.MyMessage.newBuilder().setDataType(MyDataInfo.MyMessage.DataType.StudentType).setStudent(MyDataInfo.Student.newBuilder().setId(5).setName("玉麒麟 卢俊义").build()).build();

      myMessage = MyDataInfo.MyMessage.newBuilder().setDataType(MyDataInfo.MyMessage.DataType.WorkerType).setWorker(MyDataInfo.Worker.newBuilder().setAge(20).setName("老李").build()).build();
    • 获取(服务端)(继承的SimpleChannelInboundHandler的泛型要修改成大包裹:MyDataInfo.MyMessage

      • //根据dataType 来显示不同的信息
        MyDataInfo.MyMessage.DataType dataType = msg.getDataType();
        if(dataType == MyDataInfo.MyMessage.DataType.StudentType) {
            MyDataInfo.Student student = msg.getStudent();
            System.out.println("学生id=" + student.getId() + " 学生名字=" + student.getName());
        
        } else if(dataType == MyDataInfo.MyMessage.DataType.WorkerType) {
            MyDataInfo.Worker worker = msg.getWorker();
            System.out.println("工人的名字=" + worker.getName() + " 年龄=" + worker.getAge());
        } else {
            System.out.println("传输的类型不正确");
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46

        -----





        ## 9、Netty编解码器和handler的调用机制

        ### 1、基本说明

        1. netty的组件设计:Netty的主要组件有Channel、EventLoop、ChannelFuture、ChannelHandler、ChannelPipe等
        2. **ChannelHandler充当了处理入站和出站数据的应用程序逻辑的容器**。例如,实现ChannelInboundHandler接口(或ChannelInboundHandlerAdapter),你就可以接收入站事件和数据,这些数据会被业务逻辑处理。当要给客户端发送响应时,也可以从ChannelInboundHandler冲刷数据。业务逻辑通常写在一个或者多个ChannelInboundHandler中。ChannelOutboundHandler原理一样,只不过它是用来处理出站数据的。
        3. **ChannelPipeline提供了ChannelHandler链的容器**。**以客户端应用程序为例,如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler,并被这些Handler处理**,反之则称为入站的

        ![image-20210821034142241](Netty/image-20210821034142241.png)



        ### 2、编码解码器

        1. **当Netty发送或者接受一个消息的时候,就将会发生一次数据转换。==入站消息会被解码==:从字节转换为另一种格式(比如java对象);如果是==出站消息,它会被编码成字节==**。
        2. **Netty提供一系列实用的编解码器,他们都实现了ChannelInboundHadnler或者ChannelOutboundHandler接口。在这些类中,channelRead方法已经被重写了**。以入站为例,对于每个从入站Channel读取的消息,这个方法会被调用。随后,它将调用由解码器所提供的decode()方法进行解码,并将已经解码的字节转发给ChannelPipeline中的下一个ChannelInboundHandler。



        ### 3、解码器——ByteToMessageDecoder(服务器端,入站)

        1. 关系继承图

        - ![image-20210821034417112](Netty/image-20210821034417112.png)

        2. **由于不可能知道远程节点是否会一次性发送一个完整的信息,==tcp有可能出现粘包拆包的问题==**,这个类会对入站数据进行缓冲,直到它准备好被处理。

        3. 一个关于ByteToMessageDecoder实例分析

        - ```java
        public class ToIntegerDecoder extends ByteToMessageDecoder {
        // 读取一个int类型
        @Override
        protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        if (in.readableBytes() >= 4) {
        out.add(in.readInt());
        }
        }
        }
    • 说明:

      • 这个例子,每次入站从ByteBuf中读取4字节,将其解码为一个int,然后将它添加到下一个List中。当没有更多元素可以被添加到该List中时,它的内容将会被发送给下一个ChannelInboundHandler。int在被添加到List中时,会被自动装箱为Integer
      • 在调用readInt()方法前必须验证所输入的ByteBuf是否具有足够的数据。
      • 关于if还是while的问题,”decode”方法确实会被循环调用,只要还有可读就会一直循环,除非”decode”没有再读出数据,则会退出循环。
      • 所以如果把”if”改成”while”也是可以的没有区别,相当于自己先把buf处理完了,外层循环就不会再调用了
      • decode 会根据接收的数据,被调用多次,直到确定没有新的元素被添加到list,或者是ByteBuf 没有更多的可读字节为止
      • 如果list out 不为空,就会将list的内容传递给下一个 channelinboundhandler处理,该channelinboundhandler的方法也会被调用多次(不管是if还是while,因为循环调用的依据是list的内容)

      image-20210821035333607

Netty的handler链的调用机制

实例要求:使用自定义的编码器和解码器来说明Netty的handler 调用机制

  • 客户端发送long -> 服务器
  • 服务端发送long -> 客户端

思路:

image-20210821050856155

注意:

  • ctx.write 会去调用outbound的方法
  • outbound一定要放到最后一个inbound之前,保证inbound在write的时候,可以往前找到outbound

代码:

自定义编码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class MyLongToByteEncoder extends MessageToByteEncoder<Long> {
//编码方法
@Override
protected void encode(ChannelHandlerContext ctx, Long msg, ByteBuf out) throws Exception {

System.out.println("MyLongToByteEncoder encode 被调用");
System.out.println("msg=" + msg);
out.writeLong(msg);

}
}

自定义解码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;

import java.util.List;

public class MyByteToLongDecoder extends ByteToMessageDecoder {
/**
*
* decode 会根据接收的数据,被调用多次, 直到确定没有新的元素被添加到list
* , 或者是ByteBuf 没有更多的可读字节为止
* 如果list out 不为空,就会将list的内容传递给下一个 channelinboundhandler处理, 该处理器的方法也会被调用多次
*
* @param ctx 上下文对象
* @param in 入站的 ByteBuf
* @param out List 集合,将解码后的数据传给下一个handler
* @throws Exception
*/
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {

System.out.println("MyByteToLongDecoder 被调用");
//因为 long 8个字节, 需要判断有8个字节,才能读取一个long
if(in.readableBytes() >= 8) {
out.add(in.readLong());
}
}
}

服务端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}

服务端的处理器的初始化:MyServerInitializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyServerInitializer extends ChannelInitializer<SocketChannel> {

@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();//一会下断点

//入站的handler进行解码 MyByteToLongDecoder
pipeline.addLast(new MyByteToLongDecoder());
//出站的handler进行编码
pipeline.addLast(new MyLongToByteEncoder());
//自定义的handler 处理业务逻辑
pipeline.addLast(new MyServerHandler());
System.out.println("xx");
}
}

服务端的自定义处理器:MyServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

public class MyServerHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {

System.out.println("从客户端" + ctx.channel().remoteAddress() + " 读取到long " + msg);

//给客户端发送一个long
ctx.writeAndFlush(98765L);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

public class MyClient {
public static void main(String[] args) throws Exception{

EventLoopGroup group = new NioEventLoopGroup();

try {

Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioSocketChannel.class)
.handler(new MyClientInitializer()); //自定义一个初始化类

ChannelFuture channelFuture = bootstrap.connect("localhost", 7000).sync();

channelFuture.channel().closeFuture().sync();

}finally {
group.shutdownGracefully();
}
}
}

客户端的处理器的初始化:MyClientInitializer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline pipeline = ch.pipeline();

//加入一个出站的handler 对数据进行一个编码
pipeline.addLast(new MyLongToByteEncoder());

//这时一个入站的解码器(入站handler )
pipeline.addLast(new MyByteToLongDecoder());
//加入一个自定义的handler , 处理业务
pipeline.addLast(new MyClientHandler());

}
}

客户端的自定义处理器:MyServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.util.CharsetUtil;

import java.nio.charset.Charset;

public class MyClientHandler extends SimpleChannelInboundHandler<Long> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Long msg) throws Exception {

System.out.println("服务器的ip=" + ctx.channel().remoteAddress());
System.out.println("收到服务器消息=" + msg);

}

//重写channelActive 发送数据
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("MyClientHandler 发送数据");
ctx.writeAndFlush(123456L); //发送的是一个long

//分析
//1. "abcdabcdabcdabcd" 是 16个字节
//2. 该处理器的前一个handler 是 MyLongToByteEncoder
//3. MyLongToByteEncoder 父类 MessageToByteEncoder
//4. 父类 MessageToByteEncoder
/*

public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
ByteBuf buf = null;
try {
if (acceptOutboundMessage(msg)) { //判断当前msg 是不是应该处理的类型,如果是就处理,不是就跳过encode
@SuppressWarnings("unchecked")
I cast = (I) msg;
buf = allocateBuffer(ctx, cast, preferDirect);
try {
encode(ctx, cast, buf);
} finally {
ReferenceCountUtil.release(cast);
}

if (buf.isReadable()) {
ctx.write(buf, promise);
} else {
buf.release();
ctx.write(Unpooled.EMPTY_BUFFER, promise);
}
buf = null;
} else {
ctx.write(msg, promise);
}
}
4. 因此我们编写 Encoder 是要注意传入的数据类型和处理的数据类型一致
*/
// ctx.writeAndFlush(Unpooled.copiedBuffer("abcdabcdabcdabcd",CharsetUtil.UTF_8));
}
}

执行流程:

image-20210821035744141

结论:

  • 不论解码器handler 还是 编码器handler 即接收的消息类型必须与待处理的消息类型一致,否则该handler不会被执行

    • 底层调用了父类 MessageToByteEncoder的write方法,源码:

    • public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
          ByteBuf buf = null;
          try {
              //判断当前msg 是不是应该处理的类型,如果是就处理,不是就跳过encode
              if (acceptOutboundMessage(msg)) { 
                  @SuppressWarnings("unchecked")
                  I cast = (I) msg;
                  buf = allocateBuffer(ctx, cast, preferDirect);
                  try {
                      encode(ctx, cast, buf);
                  } finally {
                      ReferenceCountUtil.release(cast);
                  }
      
                  if (buf.isReadable()) {
                      ctx.write(buf, promise);
                  } else {
                      buf.release();
                      ctx.write(Unpooled.EMPTY_BUFFER, promise);
                  }
                  buf = null;
              } else {
                  ctx.write(msg, promise);
              }
          }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - **在解码器 进行数据解码时,需要判断 缓存区(ByteBuf)的数据是否足够 ,否则接收到的结果会期望结果可能不一致**



      ### 4、解码器——ReplayingDecoder(客户端,出站)

      1. ```java
      public abstract class ReplayingDecoder<S> extends ByteToMessageDecoder
  1. ReplayingDecoder扩展了ByteToMessageDecoder类,使用这个类,我们不必调用readableBytes()方法。参数S指定了用户状态管理的类型,其中Void代表不需要状态管理

  2. ReplayingDecoder使用方便,但它也有一些局限性:

    • 并不是所有的 ByteBuf 操作都被支持,如果调用了一个不被支持的方法,将会抛出一个 UnsupportedOperationException
    • ReplayingDecoder 在某些情况下可能稍慢于 ByteToMessageDecoder,例如网络缓慢并且消息格式复杂时,消息会被拆成了多个碎片,速度变慢

5、其它编解码器

  • 其它解码器:
    1. LineBasedFrameDecoder:这个类在Netty内部也有使用,它使用行尾控制字符(\n或者\r\n)作为分隔符来解析数据
    2. DelimiterBasedFrameDecoder使用自定义的特殊字符作为消息的分隔符
    3. HttpObjectDecoder:一个HTTP数据的解码器
    4. LengthFieldBasedFrameDecoder通过指定长度来标识整包消息,这样就可以自动的处理==黏包==和==半包==消息
    5. image-20210821053430472
    6. image-20210821053547439
  • 其它编码器:
    1. image-20210821053839116
    2. image-20210821053849720
  • 例子:如果客户端传输大量数据到服务端的时候,为了节省时间与开销。可以在客户端使用ZlibEncoder对数据进行压缩编码,然后在服务端使用ZlibDecoder进行压缩阶码就能得到数据。这些操作Netty都帮助我们完成了,我们只需要在将Initializer对象的initChennel方法中将对应的编解码器加入pipeline当中

6、Log4j 整合到Netty

  1. 在Maven 中添加对Log4j的依赖 在 pom.xml

    • <dependency>
          <groupId>log4j</groupId>
          <artifactId>log4j</artifactId>
          <version>1.2.17</version>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-api</artifactId>
          <version>1.7.25</version>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-log4j12</artifactId>
          <version>1.7.25</version>
          <scope>test</scope>
      </dependency>
      <dependency>
          <groupId>org.slf4j</groupId>
          <artifactId>slf4j-simple</artifactId>
          <version>1.7.25</version>
          <scope>test</scope>
      </dependency>
      
      1
      2
      3
      4
      5
      6
      7
      8

      2. 配置 Log4j,在 resources/log4j.properties

      - ```properties
      log4j.rootLogger=DEBUG, stdout
      log4j.appender.stdout=org.apache.log4j.ConsoleAppender
      log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
      log4j.appender.stdout.layout.ConversionPattern=[%p] %C{1} - %m%n
  2. 演示:

    • image-20210821054850668

10、TCP 粘包和拆包 及解决方案

1、TCP 粘包和拆包基本介绍

  1. TCP是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发给接收端的包,更有效的发给对方,使用了优化方法(==Nagle算法==),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界
  2. 由于TCP无消息保护边界,需要在接收端处理消息边界问题,也就是我们所说的粘包、拆包问题,看一张图:
    • image-20210821155622581
  3. 客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况:
    1. 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包
    2. 服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为==TCP粘包==
    3. 服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为==TCP拆包==
    4. 服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容D1_2和完整的D2包。

2、TCP 粘包和拆包解决方案

  1. 使用自定义协议 + 编解码器 来解决
  2. 关键就是要解决 服务器端每次读取数据长度的问题,这个问题解决,就不会出现服务器多读或少读数据的问题,从而避免的TCP 粘包、拆包 。

看一个具体的实例:

  1. 要求客户端发送 5 个 Message 对象,客户端每次发送一个 Message 对象
  2. 服务器端每次接收一个Message,分5次进行解码, 每读取到 一个Message,会回复一个Message 对象 给客户端。

image-20210821155936176

代码:

由于我们是自定义协议,需要我们编写一个协议包类,规定每次发送的协议包的内容和大小(重要)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//协议包
public class MessageProtocol {
private int len; //关键
private byte[] content;

public int getLen() {
return len;
}

public void setLen(int len) {
this.len = len;
}

public byte[] getContent() {
return content;
}

public void setContent(byte[] content) {
this.content = content;
}
}

由于我们是自定义协议,需要我们自定义编解码器,将我们自定义的协议包进行编解码

编码器:

1
2
3
4
5
6
7
8
9
10
11
12
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;

public class MyMessageEncoder extends MessageToByteEncoder<MessageProtocol> {
@Override
protected void encode(ChannelHandlerContext ctx, MessageProtocol msg, ByteBuf out) throws Exception {
System.out.println("MyMessageEncoder encode 方法被调用");
out.writeInt(msg.getLen());
out.writeBytes(msg.getContent());
}
}

解码器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ReplayingDecoder;

import java.util.List;

public class MyMessageDecoder extends ReplayingDecoder<Void> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println("MyMessageDecoder decode 被调用");
//需要将得到二进制字节码-> MessageProtocol 数据包(对象)
int length = in.readInt();

byte[] content = new byte[length];
in.readBytes(content);

//封装成 MessageProtocol 对象,放入 out, 传递下一个handler业务处理
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
// 将协议包放入list当中
out.add(messageProtocol);

}
}

服务器端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;

public class MyServer {
public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

try {

ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup,workerGroup).channel(NioServerSocketChannel.class).childHandler(new MyServerInitializer()); //自定义一个初始化类


ChannelFuture channelFuture = serverBootstrap.bind(7000).sync();
channelFuture.channel().closeFuture().sync();

}finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.Charset;
import java.util.UUID;


//处理业务的handler
public class MyServerHandler extends SimpleChannelInboundHandler<MessageProtocol>{
private int count;

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
//cause.printStackTrace();
ctx.close();
}

@Override
protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {

//接收到数据,并处理
int len = msg.getLen();
byte[] content = msg.getContent();

System.out.println();
System.out.println();
System.out.println();
System.out.println("服务器接收到信息如下");
System.out.println("长度=" + len);
System.out.println("内容=" + new String(content, Charset.forName("utf-8")));

System.out.println("服务器接收到消息包数量=" + (++this.count));

//回复消息
String responseContent = UUID.randomUUID().toString();
int responseLen = responseContent.getBytes("utf-8").length;
byte[] responseContent2 = responseContent.getBytes("utf-8");
//构建一个协议包
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(responseLen);
messageProtocol.setContent(responseContent2);
ctx.writeAndFlush(messageProtocol);
}
}

客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;

public class MyClient {
public static void main(String[] args) throws Exception{

EventLoopGroup group = new NioEventLoopGroup();

try {

Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group).channel(NioSocketChannel.class)
.handler(new MyClientInitializer()); //自定义一个初始化类

ChannelFuture channelFuture = bootstrap.connect("localhost", 7000).sync();

channelFuture.channel().closeFuture().sync();

}finally {
group.shutdownGracefully();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.socket.SocketChannel;


public class MyClientInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) throws Exception {

ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast(new MyMessageEncoder()); //加入编码器
pipeline.addLast(new MyMessageDecoder()); //加入解码器
pipeline.addLast(new MyClientHandler());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.Charset;

public class MyClientHandler extends SimpleChannelInboundHandler<MessageProtocol> {

private int count;
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//使用客户端发送10条数据 "今天天气冷,吃火锅" 编号

for(int i = 0; i< 5; i++) {
String mes = "今天天气冷,吃火锅";
byte[] content = mes.getBytes(Charset.forName("utf-8"));
int length = mes.getBytes(Charset.forName("utf-8")).length;

//创建协议包对象
MessageProtocol messageProtocol = new MessageProtocol();
messageProtocol.setLen(length);
messageProtocol.setContent(content);
ctx.writeAndFlush(messageProtocol);

}

}

// @Override
protected void channelRead0(ChannelHandlerContext ctx, MessageProtocol msg) throws Exception {

int len = msg.getLen();
byte[] content = msg.getContent();

System.out.println("客户端接收到消息如下");
System.out.println("长度=" + len);
System.out.println("内容=" + new String(content, Charset.forName("utf-8")));

System.out.println("客户端接收消息数量=" + (++this.count));

}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
System.out.println("异常消息=" + cause.getMessage());
ctx.close();
}
}

效果:

客户端:

image-20210821160639783

服务端:

image-20210821160731328

调用流程说明:

  1. 客户端发送5条数据到服务端(编码5次)
  2. 服务端调用解码器将客户端发送过来的数据进行解码,收到信息后,发送一个协议包给客户端(编码1次),由于客户端发送了5条数据,所以这个过程会执行5次
  3. 客户端接收到服务端发送过来的协议包,调用解码器进行解码,接收数据。由于服务端会回发5次数据,所以客户端也会接收到5次数据,每一次接收都要调用一次解码器进行数据解码
  4. 由于数据都是通过自定义的协议包进行传输的,协议包中规定了每一次传输的数据的长度,所以不会出现TCP的粘包拆包问题。

11、Netty 核心源码剖析

1、Netty 启动过程源码剖析

说明:

  1. 源码需要剖析到Netty 调用doBind方法, 追踪到 NioServerSocketChannel的doBind
  2. 并且要Debug 程序到 NioEventLoop类 的run代码 ,无限循环,在服务器端运行。

image-20210823041445976

Netty启动过程梳理:

  1. 创建2个 EventLoopGroup 线程池数组。数组默认大小CPU*2,方便chooser选择线程池时提高性能
  2. BootStrap 将 boss 设置为 group属性,将 worker 设置为 childer 属性
  3. 通过 bind 方法启动,内部重要方法为 initAndRegisterdobind 方法
  4. initAndRegister 方法会反射创建 NioServerSocketChannel 及其相关的 NIO 的对象, pipeline , unsafe,同时也为 pipeline 初始了 head 节点和 tail 节点。
  5. register0 方法成功以后调用在 dobind 方法中调用 doBind0 方法,该方法会 调用 NioServerSocketChannel 的 doBind 方法对 JDK 的 channel 和端口进行绑定,完成 Netty 服务器的所有启动,并开始监听连接事件

2、Netty 接受请求过程源码剖析

说明:

  1. 从之前服务器启动的源码中,我们得知,服务器最终注册了一个 Accept 事件等待客户端的连接。我们也知道,NioServerSocketChannel 将自己注册到了 boss 单例线程池(reactor 线程)上,也就是 EventLoop 。
  2. 先简单说下EventLoop的逻辑(后面我们详细讲解EventLoop)
    • EventLoop 的作用是一个死循环,而这个循环中做3件事情:
      1. 有条件的等待 Nio 事件。
      2. 处理 Nio 事件。
      3. 处理消息队列中的任务。
  3. 仍用前面的项目来分析:进入到 NioEventLoop 源码中后,在private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) 方法开始调试
  4. 最终我们要分析到AbstractNioChannel 的 doBeginRead 方法, 当到这个方法时,针对于这个客户端的连接就完成了,接下来就可以监听读事件了

Netty接受请求过程梳理:

总体流程

接受连接 —–> 创建一个新的NioSocketChannel ———–> 注册到一个 worker EventLoop 上 ——–> 注册selecot Read 事件。

  1. 服务器轮询 Accept 事件,获取事件后调用 unsafe 的 read 方法,这个 unsafe 是 ServerSocket 的内部类,该方法内部由2部分组成
  2. doReadMessages 用于创建 NioSocketChannel 对象,该对象包装 JDK 的 Nio Channel 客户端。该方法会像创建 ServerSocketChanel 类似创建相关的 pipeline , unsafe,config
  3. 随后执行 pipeline.fireChannelRead 方法,并将自己绑定到一个 chooser 选择器选择的 workerGroup 中的一个 EventLoop。并且注册一个0,表示注册成功,但并没有注册读(1)事件

3、Pipeline Handler HandlerContext创建源码剖析

  1. 每当创建 ChannelSocket 的时候都会创建一个绑定的 pipeline,一对一的关系,创建 pipeline 的时候也会创建 tail 节点和 head 节点,形成最初的链表
  2. 在调用 pipeline 的 addLast 方法的时候,会根据给定的 handler 创建一个 Context,然后将这个 Context 插入到链表的尾端(tail 前面)
  3. Context 包装 handler,多个 Context 在 pipeline 中形成了双向链表
  4. 入站方向叫 inbound,由 head 节点开始,出站方法叫 outbound ,由 tail 节点开始

4、ChannelPipeline 调度 handler 的源码剖析

  1. 当一个请求进来的时候,ChannelPipeline 是如何调用内部的这些 handler 的呢?
  2. 首先,当一个请求进来的时候,会第一个调用 pipeline 的 相关方法,如果是入站事件,这些方法由 fire 开头,表示开始管道的流动。让后面的 handler 继续处理

示意图

ChannelPipeline 调度 handler 梳理:

  1. Context 包装 handler,多个 Context 在 pipeline 中形成了双向链表,入站方向叫 inbound,由 head 节点开始,出站方法叫 outbound ,由 tail 节点开始。
  2. 而节点中间的传递通过 AbstractChannelHandlerContext 类内部的 fire 系列方法,找到当前节点的下一个节点不断的循环传播。是一个过滤器形式完成对handler 的调度

5、Netty 心跳(heartbeat)服务源码剖析

Netty 作为一个网络框架,提供了诸多功能,比如编码解码等,Netty 还提供了非常重要的一个服务——心跳机制heartbeat。通过心跳检查对方是否有效,这是 RPC 框架中是必不可少的功能。

说明:

  1. Netty 提供了 IdleStateHandlerReadTimeoutHandlerWriteTimeoutHandler 三个Handler 检测连接的有效性,重点分析 IdleStateHandler
  2. image-20210823042900872

hasOutputChanged流程图:

hasOutputChanged流程图

6、Netty 核心组件 EventLoop 源码剖析

eventloop继承图:

eventloop继承图

handler 中加入线程池和Context 中添加线程池的源码剖析

  1. 在 Netty 中做耗时的,不可预料的操作,比如数据库,网络请求,会严重影响 Netty 对 Socket 的处理速度。
  2. 而解决方法就是将耗时任务添加到异步线程池中。但就添加线程池这步操作来讲,可以有2种方式,而且这2种方式实现的区别也蛮大的。
    • 处理耗时业务的第一种方式——handler 中加入线程池
    • 处理耗时业务的第二种方式——Context 中添加线程池

将这些任务从handler中提交到channel对应的NIOEventLoop 的 TaskQueue的方法:

用户程序自定义的普通任务 -> 提交到该channel 对应的NioEventLoop 的 taskQueue中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 用户程序自定义的普通任务
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵2", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});

/*
ctx.channel().eventLoop().execute(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(20 * 1000);
ctx.writeAndFlush(Unpooled.copiedBuffer("hello, 客户端~(>^ω^<)喵3", CharsetUtil.UTF_8));
} catch (Exception ex) {
System.out.println("发生异常" + ex.getMessage());
}
}
});
*/

注意:

  • 该方法是通过ctx获得channel对象,在通过channel对象去获取该channel所在的evevtLoop,最后在将任务提交到eventLoop的taskQueue中

  • eventLoop会起一个线程去异步解决taskQueue当中的任务,==注意是一个线程==。如果taskQueue当中有多个任务的话,那么该线程会按照taskQueue中任务的顺序依次执行任务,即执行taskQueue任务的时间是累加的

    • eg:taskQueue的第一个任务花费10s,taskQueue的第二个任务花费20s,那么该线程执行完taskQueue当中的任务总共要花费30s
  • 解决方法:

    1. 在当前Handler中创建一个业务线程池,把耗时任务放到创建的线程池中执行。此时就变成了一个线程有一个业务线程池,来完成耗时任务的异步操作。(局部异步)

      • 创建线程池的方法:

        • // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);
          
          // 调用一下方法将耗时任务放在线程池创建的线程中进行执行
          group.sumbit(Callable task);
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15

          2. **在Server端中创建一个业务线程池(Context中添加线程池)**(整个异步)

          - 创建线程池的方法:

          - ```java
          // 创建一个线程池,线程数为16
          // 这里是用static 创建的全局线程池,即在整一个Handler都可以使用该业务线程池
          static final EventExecutorGroup group = new DefaultEventExecutorGroup(16);

          // 在ChannelInitializer的initChannel方法中
          ChannelPipeline p = chpipeline();
          // 在这里将group设置进去:如果这样设置的话,该handler会优先加入到该线程池中,这样一来,workerGroup主要接收任务 然后在将任务提交给线程池来处理。
          // 默认没添加group的话,handler会进入workerLoopGroup的某一个workerLoop子线程
          p.addLast(group,new MyServerHandler());

流程图:

流程图


12、用Netty 自己 实现 dubbo RPC

1、RPC基本介绍

  1. RPC(Remote Procedure Call)——远程过程调用,是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程
  2. 两个或多个应用程序都分布在不同的服务器上,它们之间的调用都像是本地方法调用一样
    • image-20210823043804591
  3. 常见的 RPC 框架有:比较知名的如:
    • 阿里的Dubbo
    • google的gRPC
    • Go语言的rpcx
    • Apache的thrift
    • Spring 旗下的 Spring Cloud

2、RPC调用流程

1、RPC调用流程图

image-20210823043954338

术语说明:在RPC 中, Client 叫服务消费者,Server 叫服务提供者

2、RPC调用流程说明

  1. 服务消费方(client)以本地调用方式调用服务
  2. client stub 接收到调用后负责将方法、参数等封装成能够进行网络传输的消息体
  3. client stub 将消息进行编码并发送到服务端
  4. server stub 收到消息后进行解码
  5. server stub 根据解码结果调用本地的服务
  6. 本地服务执行并将结果返回给 server stub
  7. server stub 将返回导入结果进行编码并发送至消费方
  8. client stub 接收到消息并进行解码
  9. 服务消费方(client)得到结果

小结:RPC 的目标就是将 2-8 这些步骤都封装起来,用户无需关心这些细节,可以像调用本地方法一样即可完成远程服务调用。

3、自己实现 dubbo RPC(基于Netty)

1、需求说明

  1. dubbo 底层使用了 Netty 作为网络通讯框架,要求用 Netty 实现一个简单的 RPC 框架
  2. 模仿 dubbo,消费者和提供者约定接口和协议,消费者远程调用提供者的服务,提供者返回一个字符串,消费者打印提供者返回的数据。底层网络通信使用 Netty 4.1.20

2、设计说明

  1. 创建一个接口,定义抽象方法。用于消费者和提供者之间的约定。
  2. 创建一个提供者,该类需要监听消费者的请求,并按照约定返回数据。
  3. 创建一个消费者,该类需要透明的调用自己不存在的方法,内部需要使用 Netty 请求提供者返回数据

image-20210823044457880


参考资料

linux I/O–IO原理和几种零拷贝机制

由传统IO演化至零拷贝的过程

[TOC]

JUC高并发编程

1、Java并发知识体系详解

1、知识体系

img

2、java高并发

Java并发1

Java并发2

Java并发3

Java并发4

Java并发思维导图(含面试问题整理)


2、Java 并发 - 理论基础

从理论的角度引入并发安全问题以及JMM应对并发问题的原理。

1、BAT大厂的面试问题

  • 多线程的出现是要解决什么问题的?
  • 线程不安全是指什么? 举例说明
  • 并发出现线程不安全的本质什么?
    • 并发的三要素:可见性,原子性和有序性。
  • Java是怎么解决并发问题的?
    • 3个关键字,JMM和8个Happens-Before
  • 线程安全是不是非真即假?
    • 不是
  • 线程安全有哪些实现思路?
  • 如何理解并发和并行的区别?

2、并发与并行

1、串行模式

串行表示所有任务都按先后顺序进行

串行意味着必须先装完一车柴才能运送这车柴,只有运送到了,才能卸下这车柴,并且只有完成了这整个三个步骤,才能进行下一个步骤。

串行是一次只能取得一个任务,并执行这个任务

2、并行模式

并行意味着可以同时取得多个任务,并同时去执行所取得的这些任务

并行模式相当于将长长的一条队列,划分成了多条短队列,所以并行缩短了任务队列的长度。

并行的效率从代码层次上强依赖于多进程/多线程代码从硬件角度上则依赖于多核 CPU

多核 cpu下,每个核(core)都可以调度运行线程,这时候线程可以是并行的。

image-20210802205153125

3、并发

并发(concurrent)指的是多个程序可以同时运行的现象,更细化的是多进程可以同时运行或者多指令可以同时运行。但这不是重点,在描述并发的时候也不会去扣这种字眼是否精确,==并发的重点在于它是一种现象==, ==并发描述的是多进程同时运行的现象==。但实际上,对于单核心 CPU 来说,同一时刻只能运行一个线程,线程实际还是串行执行的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。所以,这里的”同时运行”表示的不是真的同一时刻有多个线程运行的现象,这是并行的概念,而是提供一种功能让用户看来多个程序同时运行起来了,但实际上这些程序中的进程不是一直霸占 CPU 的,而是执行一会停一会。 总结为一句话就是: ==微观串行,宏观并行== ,一般会将这种 线程轮流使用 CPU 的做法称为并发( concurrent

image-20210802204936365

要解决大并发问题,通常是将大任务分解成多个小任务,由于操作系统对进程的调度是随机的,所以切分成多个小任务后,可能会从任一小任务处执行。这可能会出现一些现象:

  • 可能出现一个小任务执行了多次,还没开始下个任务的情况。这时一般会采用队列或类似的数据结构来存放各个小任务的成果;
  • 可能出现还没准备好第一步就执行第二步的可能。这时,一般采用多路复用或异步的方式,比如只有准备好产生了事件通知才执行某个任务;
  • 可以多进程/多线程的方式并行执行这些小任务。也可以单进程/单线程执行这些小任务,这时很可能要配合多路复用才能达到较高的效率。

4、并发与并行的区别

  • 并发是指一个处理器同时处理多个任务。并行是指多个处理器或者是多核的处理器同时处理多个不同的任务。并发是逻辑上的同时发生(simultaneous),而并行是物理上的同时发生。

  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。就好像两个人各拿一把铁锨在挖坑,一小时后,每人一个大坑。所以无论从微观还是从宏观来看,二者都是一起执行的。

    并行和并发哪个好?并行和并发的概念和区别

  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。这就好像两个人用同一把铁锨,轮流挖坑,一小时后,两个人各挖一个小一点的坑,要想挖两个大一点得坑,一定会用两个小时。

    并行和并发哪个好?并行和并发的概念和区别

  • 并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)。(下图来自Erlang 之父 Joe Armstrong)

    img

  • 当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。这种方式我们称之为并发(Concurrent)。

  • 当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

    并行和并发哪个好?并行和并发的概念和区别

  • 引用 Rob Pike(golang 语言的创造者) 的一段描述:

    • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
    • 并行(parallel)是同一时间动手做(doing)多件事情的能力
    • 例子:
      • 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
      • 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
      • 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
  • 并发:同一时刻多个线程在访问同一个资源,多个线程对一个点

    • 例子:春运抢票 电商秒杀…
  • 并行:多项工作一起执行,之后再汇总

    • 例子:泡方便面,电水壶烧水,一边撕调料倒入桶中

5、管程

管程(monitor)是保证了同一时刻只有一个进程在管程内活动,即**管程内定义的操作在同一时刻只被一个进程调用(由编译器实现)**。但是这样并不能保证进程以设计的顺序执行。

JVM 中同步是基于进入和退出管程(monitor)对象实现的,每个对象都会有一个管程(monitor)对象,管程(monitor)会随着 java 对象一同创建和销毁。

执行线程首先要持有管程对象,然后才能执行方法,当方法完成之后会释放管程,方法在执行时候会持有管程,其他线程无法再获取同一个管程。

管程,在java中叫锁,在操作系统中叫监视器,是一种同步机制。

image-20210721233218350

6、用户线程和守护线程

  • 用户线程:平时用到的普通线程、自定义线程

  • 守护线程:运行在后台,是一种特殊的线程。比如垃圾回收线程。

  • 当主线程结束后,用户线程还在运行,JVM 存活;

  • 如果没有用户线程,都是守护线程,JVM 结束 。

    image-20210721233249742

  • 可以通过调用Thread.currentThread().isDaemon()查看当前线程是不是守护线程

  • 可以通过调用当前线程.setDaemon(true)将当前线程设置为守护线程

    • 这个方法应该在当前线程.start()执行之前设置

注意:

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

3、为什么需要多线程

众所周知,CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异;
    • 导致 可见性问题
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
    • 导致 原子性问题
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
    • 导致 有序性问题

4、多线程的应用

1、应用之异步调用

1、异步与同步

以调用方角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步
2、设计

多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…

3、结论
  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
  • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

2、应用之提高效率

充分利用多核 cpu 的优势,提高运行效率。想象下面的场景,执行 3 个计算,最后将计算结果汇总:

1
2
3
4
计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms
  • 如果是串行执行,那么总共花费的时间是 10 + 11 + 9 + 1 = 31ms
  • 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3,那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即 11ms 最后加上汇总时间只会花费 12ms

注意:需要在多核 cpu 才能提高效率,单核仍然时是轮流执行

结论
  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
  2. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
    • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
    • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
  3. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化

5、线程不安全示例

如果多个线程同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。

以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。

1
2
3
4
5
6
7
8
9
public class ThreadUnsafeExample {
private int cnt = 0;
public void add() {
cnt++;
}
public int get() {
return cnt;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
ThreadUnsafeExample example = new ThreadUnsafeExample();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}

结果:

1
997 // 结果总是小于1000

6、并发出现问题的根源:并发三要素

上述代码输出的值为什么总是小于1000?并发出现问题的根源是什么?

  • 并发的三要素
    • 可见性
    • 原子性
    • 有序性

1、可见性:CPU缓存引起

可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。

例子:

代码:

1
2
3
4
5
6
//线程1执行的代码
int i = 0; // 在主存中的值
i = 10; // 在CPU1中高速缓存中

//线程2执行的代码
j = i; // 读取的依旧是主存当中i的值,对于CPU修改的i的值不可见

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10。

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值

2、原子性:分时复用引起

原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

经典的银行取钱问题:比如从账户A和账户B同时对一个银行账号取钱1000元,那么必然包括2个操作:

  1. 从银行账号读取余额,取钱1000元
  2. 取完钱之后,银行将账号的余额进行更新(-1000)

试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A取钱1000元之后,操作突然中止。然后又从B取出了1000元,取出1000元之后,再执行银行余额更新减去1000元的操作。这样就会导致账号减去了1000元,但是账户A与账户B一共取到了2000元。

所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

3、有序性:重排序引起

有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

1
2
3
4
int i = 0;              
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?

  • 不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
1、CPU的指令重排序
1、名词
  • Clock Cycle Time:主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的Cycle Time 是 1s
    • 例如,运行一条加法指令一般需要一个时钟周期时间
  • CPI:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数
  • IPC:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数
  • CPU 执行时间:程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示
    • 程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
2、指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?

可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这 5 个阶段

image-20210806220551047

术语参考:

  • instruction fetch (IF)
  • instruction decode (ID)
  • execute (EX)
  • memory access (MEM)
  • register write back (WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。

指令重排的前提是,重排指令不能影响结果,例如:

1
2
3
4
5
6
7
 // 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2
3、支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。

image-20210806220613006

4、SuperScalar 处理器

大多数处理器包含多个执行单元,并不是所有计算功能都集中在一起,可以再细分为整数运算单元、浮点数运算单元等,这样可以把多条指令也可以做到并行获取、译码等,CPU 可以在一个时钟周期内,执行多于一条指令,IPC > 1

image-20210806220634123

2、重排序(Java 内存模型JMM)
1、重排序的分类

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

img

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

2、处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:

1
2
3
4
5
6
7
8
9
// Processor A
a = 1; //A1
x = b; //A2

// Processor B
b = 2; //B1
y = a; //B2

// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:

img

这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。

下面是常见处理器允许的重排序类型的列表:

Load-Load Load-Store Store-Store Store-Load 数据依赖
sparc-TSO N N N Y
x86 N N N Y
ia64 Y Y Y Y
PowerPC Y Y Y Y

上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。

从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。

  • ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
  • ※注 2:上表中的 x86 包括 x64 及 AMD64。
  • ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
  • ※注 4:数据依赖性后文会专门说明。

为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore Barriers Store1; StoreStore; Store2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore Barriers Load1; LoadStore; Store2 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1; StoreLoad; Load2 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。

StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)

3、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称 代码示例 说明
写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

注意:

  • 这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,
  • 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑
4、as-if-serial 语义

as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

1
2
3
double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

img

如上图所示:

  1. A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。
  2. 因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。
  3. 但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。

下图是该程序的两种执行顺序:

img

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

5、程序顺序规则

根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:

  • A happens- before B;
  • B happens- before C;
  • A happens- before C;

这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。

这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。

6、重排序对多线程的影响

重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ReorderExample {
int a = 0;
boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}

flag 变量是个标记,用来标识变量 a 是否已被写入。

这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?

答案是:不一定能看到。

由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:(注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。)

img

如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!

下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:

img

在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。

从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!

结论:

  • 在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);
  • 在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果
3、顺序一致性(Java 内存模型JMM)
1、数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量
  • 在另一个线程读同一个变量
  • 而且写和读没有通过同步来排序

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序

JMM 对正确同步的多线程程序的内存一致性做了如下保证:

  • 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)
  • 程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
  • 这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
2、顺序一致性内存模型

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序来执行
  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序

在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:

img

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

  • 假设有两个线程 A 和 B 并发执行。
    • 其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。
    • B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
  • 假设这两个线程使用监视器来正确同步:
    • A 线程的三个操作执行后释放监视器,
    • 随后 B 线程获取同一个监视器。

那么程序在顺序一致性模型中的执行效果将如下图所示:

img

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

img

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如:在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

3、同步程序的顺序一致性效果

下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
a = 1;
flag = true;
}

public synchronized void reader() {
if (flag) {
int i = a;
// ……
}
}
}

上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。

下面是该程序在两个内存模型中的执行时序对比图:

img

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门

4、未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来

为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。

和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
  • JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。

第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:

img

如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。

在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

img

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A”写了一半”的无效值。

4、总结
1、处理器内存模型

顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。

根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:

  • 放松了程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
  • 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
  • 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。

注意:

  • 这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。

下面的表格展示了常见处理器内存模型的细节特征:

内存模型名称 对应的处理器 Store-Load 重排序 Store-Store 重排序 Load-Load 和 Load-Store 重排序 可以更早读取到其它处理器的写 可以更早读取到当前处理器的写
TSO sparc-TSO X64 Y Y
PSO sparc-PSO Y Y Y
RMO ia64 Y Y Y Y
PowerPC PowerPC Y Y Y Y Y

在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在前面说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写

上面表格中的各种处理器内存模型,从上到下,模型由强变弱越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能

由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:

img

如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。

2、JMM、处理器内存模型与顺序一致性内存模型之间的关系
  • JMM 是一个语言级的内存模型
  • 处理器内存模型是硬件级的内存模型
  • 顺序一致性内存模型是一个理论参考模型

下面是语言内存模型、处理器内存模型和顺序一致性内存模型的强弱对比示意图:

img

从上图我们可以看出:

  • 常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱
  • 处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。
  • 同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
3、JMM 的设计

从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:

  • 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码
  • 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型

由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:

  1. 一方面要**为程序员提供足够强的内存可见性保证**;
  2. 另一方面,**对编译器和处理器的限制要尽可能的放松**。

下面让我们看看 JSR-133 是如何实现这一目标的。为了具体说明,请看前面提到过的计算圆面积的示例代码:

1
2
3
double pi  = 3.14;    //A
double r = 1.0; //B
double area = pi * r * r; //C

上面计算圆的面积的示例代码存在三个 happens- before 关系:

  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;

由于 A happens- before B,happens- before 的定义会要求:

  1. A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。
  2. 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。
  3. 也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:
    • 会改变程序执行结果的重排序。
    • 不会改变程序执行结果的重排序。

JMM 对这两种不同性质的重排序,采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。

下面是 JMM 的设计示意图:

img

从上图可以看出两点:

  • JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens - before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
  • JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除(锁消除)。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
4、JMM 的内存可见性保证

Java 程序的内存可见性保证按程序类型可以分为下列三类:

  • 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
  • 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证
  • 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。

下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:

img

只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。

5、JSR-133 对旧内存模型的修补

JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:

  • 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
  • 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。

7、JAVA是怎么解决并发问题的:JMM(Java内存模型)

JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。

JMM 体现在以下几个方面:

  • 原子性 - 保证指令不会受到线程上下文切换的影响
  • 可见性 - 保证指令不会受 cpu 缓存的影响
  • 有序性 - 保证指令不会受 cpu 指令并行优化的影响
  • 理解的第一个维度:核心知识点

    • JMM本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
      • volatilesynchronizedfinal 三个关键字
      • Happens-Before 规则
  • 理解的第二个维度:可见性,有序性,原子性

    • 原子性

      • 在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

      • 请分析以下哪些操作是原子性操作:

        1. x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
          
          1
          2
          3
          4

          2. ```java
          y = x;
          //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及将x的值写入工作内存这2个操作都是原子性操作,但是合起来就不是原子性操作了。
        2. x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
          
          1
          2
          3

          4. ```java
          x = x + 1; //语句4: 同语句3
      • 上面4个语句只有语句1的操作具备原子性。

      • 也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

      • 从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronizedLock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

    • 可见性

      • Java提供了volatile关键字来保证可见性。
      • 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
      • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
      • 另外,通过synchronizedLock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
    • 有序性

      • 在Java里面,可以通过volatile关键字来保证一定的”有序性”(具体原理在下面讲述)。另外可以通过synchronizedLock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。当然JMM是通过Happens-Before 规则来保证有序性的

1、关键字:volatile、synchronized 和 final

1、volatile
2、synchronized
3、final

2、Happens-Before 规则

上面提到了可以用 volatilesynchronized保证有序性。除此之外,JVM 还规定了先行发生(Happens-Before)原则让一个操作无需控制就能先于另一个操作完成

从 JDK5 开始,java 使用新的 JSR-133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。

happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见:(变量都是指成员变量或静态成员变量)

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    • static int x;
      static Object m = new Object();
      new Thread(()->{
          synchronized(m) {
              x = 10;
          }
      },"t1").start();
      
      new Thread(()->{
          synchronized(m) {
              System.out.println(x);
          }
      },"t2").start();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

      - ```java
      volatile static int x;
      new Thread(()->{
      x = 10;
      },"t1").start();
      new Thread(()->{
      System.out.println(x);
      },"t2").start();
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见

    • static int x;
      x = 10;
      new Thread(()->{
          System.out.println(x); 
      },"t2").start();
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

      - ```java
      static int x;
      Thread t1 = new Thread(()->{
      x = 10;
      },"t1");
      t1.start();
      t1.join();
      System.out.println(x);
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)

    • static int x;
      public static void main(String[] args) {
          Thread t2 = new Thread(()->{
              while(true) {
                  if(Thread.currentThread().isInterrupted()) {
                      System.out.println(x);
                      break;
                  }
              }
          },"t2");
          t2.start();
      
          new Thread(()->{
              sleep(1);
              x = 10;
              t2.interrupt();
          },"t1").start();
      
          while(!t2.isInterrupted()) {
              Thread.yield();
          }
          System.out.println(x);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

      - 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

      - 具有传递性,如果 `x hb-> y` 并且 `y hb-> z` 那么有 `x hb-> z` ,配合 `volatile` 的防指令重排,有下面的例子

      - ```java
      volatile static int x;
      static int y;
      new Thread(()->{
      y = 10;
      x = 20;
      },"t1").start();

      new Thread(()->{
      // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
      System.out.println(x);
      },"t2").start();

注意:

  • 两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!
  • happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

happens-before 与 JMM 的关系如下图所示:

img

如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。

1、单一线程原则(Single Thread rule)

在一个线程内,在程序前面的操作先行发生于后面的操作。

image

2、管程锁定规则(Monitor Lock Rule)

一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

image

3、volatile 变量规则(Volatile Variable Rule)

对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

image

4、线程启动规则(Thread Start Rule)

Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

image

5、线程加入规则(Thread Join Rule)

Thread 对象的结束先行发生于 join() 方法返回。

image

6、线程中断规则(Thread Interruption Rule)

对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。

7、对象终结规则(Finalizer Rule)

一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

8、传递性(Transitivity)

如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

8、线程安全:不是一个非真即假的命题

一个类在可以被多个线程安全调用时就是线程安全的。

线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类:

  1. 不可变
  2. 绝对线程安全
  3. 相对线程安全
  4. 线程兼容
  5. 线程对立。

1、不可变(Immutable)

不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。

多线程环境下,应当尽量使对象成为不可变,来满足线程安全

不可变的类型:

  • final 关键字修饰的基本数据类型

  • String

  • 枚举类型

  • Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。

    • 但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
  • 日期格式转换类 DateTimeFormatter

    • 平常用的的日期格式转换类 SimpleDateFormat 在多线程下是不安全的,有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果

    • 在 Java 8 后通过了 DateTimeFormatter 解决这个问题,在文档中你可以发现对DateTimeFormatter的描述:

      1
      2
      @implSpec
      This class is immutable and thread-safe.

对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。

Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常

1
2
3
4
5
6
7
public class ImmutableExample {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
unmodifiableMap.put("a", 1);
}
}
1
2
3
public V put(K key, V value) {
throw new UnsupportedOperationException();
}
1
2
3
Exception in thread "main" java.lang.UnsupportedOperationException
at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
at ImmutableExample.main(ImmutableExample.java:9)
1、不可变的设计要素

String为例,说明一下不可变设计的要素:

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];

/** Cache the hash code for the string */
private int hash; // Default to 0

/** use serialVersionUID from JDK 1.0.2 for interoperability */
private static final long serialVersionUID = -6849794470754667710L;

// ...
}
  • String整一个类被final修饰了,保证了String没有任何子类,所以也不用担心子类去修改重写它的方法而导致破坏不可变性
  • hash虽然没有加上什么final修饰,但是hash是私有的并且String类没有提供hash的set方法,外部没有办法修改hash的值,所以也算保证了hash的不可变性
  • char[]数组使用了final修饰,在构造方法当中赋值,保证了value值的不可变性;
  • 但是这样只是保证了char[]数组这个引用变量的不可变性,怎么保证char[]数组里面的值具有不可变性呢?
    • 主要是依赖了String的构造方法。

String的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 无参
public String() {
this.value = "".value;
}

// 传递一个原始字符串,根据该字符串生成新字符串,它会与原始字符串共用一个value数组
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}

// 传递一个char[]数组,它会对char[]数组的内容进行拷贝,就复制一个新数组,新数组在作为String的value数组
// 这种思想:保护性拷贝
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
2、保护性拷贝(defensive copy)

使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那不就破坏了String的不可变性了吗?那么下面就看一看这些方法是如何实现的,就以 substring 为例:

1
2
3
4
5
6
7
8
9
10
11
12
public String substring(int beginIndex) {
// 一些常规判断
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
// 主要是这里:它会新new一个String并把value作为参数传递进去,保证不可变性
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出了修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
// Note: offset or count might be near -1>>>1.
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
// 它会对数组的内容进行拷贝,就复制一个新数组,新数组在作为String的value数组(保护性拷贝)
this.value = Arrays.copyOfRange(value, offset, offset+count);
}

结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

2、绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。

3、相对线程安全

相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。

对于下面的代码,如果删除元素的线程删除了 Vector 的一个元素,而获取元素的线程试图访问一个已经被删除的元素,那么就会抛出 ArrayIndexOutOfBoundsException。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class VectorUnsafeExample {
private static Vector<Integer> vector = new Vector<>();

public static void main(String[] args) {
while (true) {
for (int i = 0; i < 100; i++) {
vector.add(i);
}
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
executorService.execute(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
});
executorService.shutdown();
}
}
}
1
2
3
4
5
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
at java.util.Vector.remove(Vector.java:831)
at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

如果要保证上面的代码能正确执行下去,就需要对删除元素和获取元素的代码进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
executorService.execute(() -> {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.get(i);
}
}
});

4、线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。

5、线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免

9、线程安全的实现方法

1、互斥同步

synchronizedReentrantLock

2、非阻塞同步

互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

1、CAS(JUC中CAS, Unsafe和原子类相关)

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:==先进行操作==**,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)**。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

乐观锁需要==操作和冲突检测这两个步骤具备原子性==,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:**比较并交换(Compare-and-Swap,CAS)**。

CAS 指令需要有 3 个操作数,分别是:

  • 内存地址 V
  • 旧的预期值 A
  • 新值 B。

当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。(否则一直循环重试,直到成功为止)

2、AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet()getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作

以下代码使用了 AtomicInteger 执行了自增的操作:

1
2
3
4
5
private AtomicInteger cnt = new AtomicInteger();

public void add() {
cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt()

1
2
3
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,其中:

  • var1 指示对象内存地址
  • var2 指示该字段相对对象内存地址的偏移
  • var4 指示操作需要加的数值,这里为 1

具体过程:

  1. 通过 getIntVolatile(var1, var2) 得到旧的预期值;
  2. 通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
  3. 可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
1
2
3
4
5
6
7
8
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;
}
3、ABA

ABA问题:如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效

3、无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

1、栈封闭(JUC中线程池相关)

多个线程访问同一个方法的==局部变量==时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StackClosedExample {
public void add100() {
int cnt = 0;
for (int i = 0; i < 100; i++) {
cnt++;
}
System.out.println(cnt);
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {
StackClosedExample example = new StackClosedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> example.add100());
executorService.execute(() -> example.add100());
executorService.shutdown();
}
1
2
100
100
2、线程本地存储(Thread Local Storage)(JUC中ThreadLocal详解)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“==一个请求对应一个服务器线程”(Thread-per-Request)==的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。

可以使用 ==java.lang.ThreadLocal== 类来实现线程本地存储功能。

对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal threadLocal = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadLocal.get());
threadLocal.remove();
});
Thread thread2 = new Thread(() -> {
threadLocal.set(2);
threadLocal.remove();
});
thread1.start();
thread2.start();
}
}

输出结果:1

为了理解 ThreadLocal,先看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadLocalExample1 {
public static void main(String[] args) {
ThreadLocal threadLocal1 = new ThreadLocal();
ThreadLocal threadLocal2 = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal1.set(1);
threadLocal2.set(1);
});
Thread thread2 = new Thread(() -> {
threadLocal1.set(2);
threadLocal2.set(2);
});
thread1.start();
thread2.start();
}
}

它所对应的底层结构图为:

image

每个 Thread 都有一个 ==ThreadLocal.ThreadLocalMap 对象==,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员。

1
2
3
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLoca -> value 键值对插入到该 Map 中。

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

get() 方法类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

hreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争

注意:

  • **在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ==ThreadLocal 有内存泄漏的情况==**;
  • 应该尽可能在每次使用 ThreadLocal 后==手动调用 remove()==,以==避免出现 ThreadLocal 经典的内存泄漏==甚至是造成自身业务混乱的风险。
3、可重入代码(Reentrant Code)

这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。

可重入代码有一些共同的特征:

  • 例如不依赖存储在堆上的数据和公用的系统资源
  • 用到的状态量都由参数中传入
  • 不调用非可重入的方法等。
4、无状态

在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的

因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】


3、Java 并发 - 线程基础

1、BAT大厂的面试问题

  • 线程有哪几种状态?分别说明从一种状态到另一种状态转变有哪些方式?
  • 通常线程有哪几种使用方式?
  • 基础线程机制有哪些?
  • 线程的中断方式有哪些?
  • 线程的互斥同步方式有哪些?如何比较和选择?
  • 线程之间有哪些协作方式?

2、进程与线程

1、进程

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

在当代面向线程设计的计算机结构中,进程是线程的容器

进程:

  • 是程序的实体;
  • 是计算机中的程序关于某数据集合上的一次运行活动;
  • 是系统进行资源分配和调度的基本单位;
  • 是操作系统结构的基础。
  • 程序是指令、数据及其组织形式的描述,进程是程序的实体。

进程:

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器
    等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

2、线程

线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

线程:

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
  • Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器

3、进程与线程的区别

  • 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是进程;
    • 进程——资源分配的最小单位。
  • 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流。
    • 线程——程序执行的最小单位。
  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPC(Inter-process communication)
      • 信号量:信号量是一个计数器,用于多进程对共享数据的访问,解决同步相关的问题并避免竞争条件
      • 共享存储:多个进程可以访问同一块内存空间,需要使用信号量用来同步对共享存储的访问
      • 管道通信:管道是用于连接一个读进程和一个写进程以实现它们之间通信的一个共享文件,pipe文件
        • 匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信,只支持半双工通信
        • 命名管道(Names Pipes):以磁盘文件的方式存在,可以实现本机任意两个进程通信,遵循FIFO
      • 消息队列:内核中存储消息的链表,由消息队列标识符标识,能在不同进程之间提供全双工通信,对比管道:
        • 匿名管道存在于内存中的文件;命名管道存在于实际的磁盘介质或者文件系统;消息队列存放在内核中,只有在内核重启(操作系统重启)或者显示地删除一个消息队列时,该消息队列才被真正删除
        • 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
      • 套接字:与其它通信机制不同的是,它可用于不同机器间的进程通信
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
    • Java中的通信机制:volatile、等待/通知机制、join方式、InheritableThreadLocal、MappedByteBuffer
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

3、线程状态转换

1、线程的五状态模型(操作系统)

image-20210803042737724

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
  • 【运行状态】指获取了 CPU 时间片运行中的状态
    • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
  • 【阻塞状态】
    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
    • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

2、线程的七状态模型(操作系统)

img

3、线程的六状态模型(java)

这是从 Java API 层面来描述的

根据 Thread.State 枚举,分为六种状态:

image-20210803043141966

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【==可运行状态==】、【==运行状态==】和【==阻塞状态==】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
  • BLOCKEDWAITINGTIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述
  • TERMINATED 当线程代码运行结束

image

假设有线程 Thread t

  1. NEW –> RUNNABLE
    • 当调用 t.start() 方法时,由 NEW --> RUNNABLE
  2. RUNNABLE <–> WAITING
    • t 线程用 synchronized(obj) 获取了对象锁后
      • 调用 obj.wait() 方法时,t 线程从 RUNNABLE --> WAITING
      • 调用 obj.notify()obj.notifyAll()t.interrupt()
        • 线程被notify之后直接从waitset进入entrylist,对应的状态就是WAITING --> BLOCKED
        • 等到锁释放之后,t线程进入锁的竞争
          • 竞争锁成功,t 线程从 WAITING --> RUNNABLE
          • 竞争锁失败,t 线程从 WAITING --> BLOCKED
  3. RUNNABLE <–> WAITING
    • 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING
      • 注意是当前线程在t 线程对象的监视器上等待
    • t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 WAITING --> RUNNABLE
  4. RUNNABLE <–> WAITING
    • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE
  5. RUNNABLE <–> TIMED_WAITING
    • t 线程用 synchronized(obj) 获取了对象锁后
      • 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
      • t 线程等待时间超过了 n 毫秒,或调用 obj.notify()obj.notifyAll()t.interrupt()
        • 线程被notify之后直接从waitset进入entrylist,对应的状态就是WAITING --> BLOCKED
        • 等到锁释放之后,t线程进入锁的竞争
          • 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
          • 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
  6. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING
      • 注意是当前线程在t 线程对象的监视器上等待
    • 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从TIMED_WAITING --> RUNNABLE
  7. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
    • 当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING --> RUNNABLE
  8. RUNNABLE <–> TIMED_WAITING
    • 当前线程调用 LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE
  9. RUNNABLE <–> BLOCKED
    • t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
    • 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争成功,从 BLOCKED --> RUNNABLE ,其它失败的线程仍然 BLOCKED
  10. RUNNABLE <–> TERMINATED
    • 当前线程所有代码运行完毕,进入 TERMINATED

线程一共有六种状态:

  • 新建(new)
  • 可运行(runnable)
  • 阻塞(blocking)
  • 无限期等待(waiting)
  • 限期等待(timed waiting)
  • 死亡(terminated)
1、新建(New)

创建后尚未启动

2、可运行(Runnable)

可能正在运行,也可能正在等待 CPU 时间片。

包含了操作系统线程状态中的 RunningReady

3、阻塞(Blocking)

等待获取一个排它锁,如果其线程释放了锁就会结束此状态

4、无限期等待(Waiting)

等待其它线程显式地唤醒,否则不会被分配 CPU 时间片

进入方法 退出方法
没有设置 Timeout 参数的 Object.wait() 方法 Object.notify() / Object.notifyAll()
没有设置 Timeout 参数的 Thread.join() 方法 被调用的线程执行完毕
LockSupport.park() 方法 -
5、限期等待(Timed Waiting)

无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。

  • 调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
  • 调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。

睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态

阻塞和等待的区别:

  • 阻塞是被动的,它是在等待获取一个排它锁
  • 等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入
进入方法 退出方法
Thread.sleep() 方法 时间结束
设置了 Timeout 参数的 Object.wait() 方法 时间结束 / Object.notify() / Object.notifyAll()
设置了 Timeout 参数的 Thread.join() 方法 时间结束 / 被调用的线程执行完毕
LockSupport.parkNanos() 方法 -
LockSupport.parkUntil() 方法 -
6、死亡(Terminated)

可以是线程结束任务之后自己结束,或者产生了异常而结束。

4、线程的四种使用方式

有三种使用线程的方法:

  • 实现 Runnable 接口
  • 实现 Callable 接口
  • 继承 Thread 类
  • 使用线程池

实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。

1、实现 Runnable 接口

  1. 编写需要的类并实现Runnable接口,实现里面的 run() 方法。
  2. 通过 Thread 调用 start() 方法来启动线程。

代码:

1
2
3
4
5
6
public class MyRunnable implements Runnable {
@Override
public void run() {
// ...
}
}
1
2
3
4
5
6
public static void main(String[] args) {
MyRunnable instance = new MyRunnable();
Thread thread = new Thread(instance);
thread.start();
}

实现 Runnable 接口的优缺点:

  • 缺点:代码复杂一点。
  • 优点:
    1. 线程任务类只是实现了Runnable接口,可以继续继承其他类,避免了单继承的局限性
    2. 同一个线程任务对象可以被包装成多个线程对象
    3. 适合多个多个线程去共享同一个资源
    4. 实现解耦操作,线程任务代码可以被多个线程共享,线程任务代码和线程独立
    5. 线程池可以放入实现Runnable或Callable线程任务对象

2、实现 Callable 接口

与Runnable 接口大致相同:

  1. 编写需要的类并实现Callable接口,实现里面的 call() 方法,该方法有返回值
  2. 通过 Thread 调用 start() 方法来启动线程。

区别是:与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装

1
2
3
4
5
6
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
return 123;
}
}
1
2
3
4
5
6
7
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<Integer> ft = new FutureTask<>(mc);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}

实现 Callable 接口的优缺点:

  • 优点:同 Runnable,并且能得到线程执行的结果
  • 缺点:编码复杂

3、继承 Thread 类

同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。

当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。

建议线程先创建子线程,主线程的任务放在之后,否则主线程(main)永远是先执行完

1
2
3
4
5
6
public class MyThread extends Thread {
@Override
public void run() {
// ...
}
}
1
2
3
4
public static void main(String[] args) {
MyThread mt = new MyThread();
mt.start();
}

继承 Thread 类的优缺点:

  • 优点:编码简单
  • 缺点:线程类已经继承了Thread类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)

4、使用线程池

Java标准库提供了ExecutorService接口表示线程池,因为ExecutorService只是接口,Java标准库提供的几个常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。

创建这些线程池的方法都被封装到Executors这个类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.concurrent.*;

public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}

class Task implements Runnable {
private final String name;

public Task(String name) {
this.name = name;
}

@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}

线程池的具体细节放在下面线程池篇具体说明

5、Thread 与 Runnable 的底层关系

使用Runnable的方法创建线程的代码:

1
2
Thread t = new Thread(()->{ log.debug("running"); }, "t2");
t.start();

Thread底层代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Thread的一个构造函数
public Thread(Runnable target, String name) {
init(null, target, name, 0);
}

// init调用了Thread的init初始化函数,其中将Runnable作为target对象传入
private void init(ThreadGroup g, Runnable target, String name,
long stackSize) {
init(g, target, name, stackSize, null, true);
}

// 该init函数调用了重载的其他init函数
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
if (name == null) {
throw new NullPointerException("name cannot be null");
}

this.name = name;

Thread parent = currentThread();
SecurityManager security = System.getSecurityManager();
if (g == null) {
if (security != null) {
g = security.getThreadGroup();
}
if (g == null) {
g = parent.getThreadGroup();
}
}
g.checkAccess();

if (security != null) {
if (isCCLOverridden(getClass())) {
security.checkPermission(SUBCLASS_IMPLEMENTATION_PERMISSION);
}
}

g.addUnstarted();

this.group = g;
this.daemon = parent.isDaemon();
this.priority = parent.getPriority();
if (security == null || isCCLOverridden(parent.getClass()))
this.contextClassLoader = parent.getContextClassLoader();
else
this.contextClassLoader = parent.contextClassLoader;
this.inheritedAccessControlContext =
acc != null ? acc : AccessController.getContext();
// 主要是这一句代码:将Runnable对象的target赋值给Thread本身的target
this.target = target;
setPriority(priority);
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
/* Stash the specified stack size in case the VM cares */
this.stackSize = stackSize;

/* Set thread ID */
tid = nextThreadID();
}

// 在该init函数里面将Runnable对象的target赋值给Thread本身的target,然后在Thread内部调用了run方法
@Override
public void run() {
if (target != null) {
target.run();
}
}

总结:

  • Thread类本身实现了Runnable接口
  • 如果直接使用Thread的方式创建线程对象,则原理是重写了Thread的run方法
  • 如果使用的Runnable的方式创建线程对象,在原理是将Runnable对象封装成target,在Thread中调用target.run方法

6、实现接口 VS 继承 Thread

实现接口会更好一些,因为:

  • 使用接口更容易与线程池等高级 API 配合
  • 使用接口让任务类脱离了 Thread 继承体系,更灵活
  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大

7、调用start()方法,线程是否会马上创建?

  • 线程不一定马上创建的
  • 看start()方法的源码知道start()方法底层调用了start0()方法,这是一个被native修饰的方法,它的调用依赖于操作系统
  • 当操作系统认为当前可以创建线程的时候,线程才会被创建

8、查看进程线程的方法

  • windows
    • 任务管理器可以查看进程和线程数,也可以用来杀死进程
    • tasklist 查看进程
    • taskkill 杀死进程
  • linux
    • ps -fe 查看所有进程
    • ps -fT -p <PID> 查看某个进程(PID)的所有线程
    • kill 杀死进程
    • top 按大写 H 切换是否显示线程
    • top -H -p <PID> 查看某个进程(PID)的所有线程
  • Java
    • jps 命令 查看所有 Java 进程
    • jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
    • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
      • jconsole 远程监控配置:
        • 需要以如下方式运行你的 java 类
          • java -Djava.rmi.server.hostname=ip地址 -Dcom.sun.management.jmxremote -
            Dcom.sun.management.jmxremote.port=连接端口 -Dcom.sun.management.jmxremote.ssl=是否安全连接 -Dcom.sun.management.jmxremote.authenticate=是否认证 java类
        • 修改 /etc/hosts 文件将 127.0.0.1 映射至主机名
        • 如果要认证访问,还需要做如下步骤:
          • 复制 jmxremote.password 文件
          • 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写
          • 连接时填入 controlRole(用户名),R&D(密码)

9、线程运行的原理

1、栈与栈帧

JVM 中由堆、栈、方法区所组成。

Java Virtual Machine Stacks (Java 虚拟机栈):每个线程启动后,虚拟机就会为其分配一块栈内存

我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?

其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2、线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。原因:

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Java 创建的线程是内核级线程,线程的调度是在内核态运行的,而线程中的代码是在用户态运行,所以线程切换(状态改变)会导致用户与内核态转换,这是非常消耗性能
  • Java 中 main 方法启动的是一个进程也是一个主线程,main 方法里面的其他线程均为子线程

5、线程的常见方法

方法名 static(静态) 功能说明 注意
start() 启动一个新线程,在新的线程运行 run 方法中的代码 start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现 IllegalThreadStateException
run() 新线程启动后会调用的方法 如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为
join() 等待线程运行结束
join(long n) 等待线程运行结束,最多等待 n 毫秒
getId() 获取线程长整型的 id id 唯一
getName() 获取线程名
setName(String) 修改线程名
getPriority() 获取线程优先级
setPriority(int) 修改线程优先级 java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率
getState() 获取线程状态 Java 中线程状态是用 6 个 enum 表示,分别为:NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
isInterrupted() 判断是否被打断 不会清除==打断标记==
isAlive() 线程是否存活(还没有运行完毕)
interrupt() 打断线程 如果被打断线程正在 sleepwaitjoin 会导致被打断的线程抛出 InterruptedException,并清除==打断标记== ;如果打断的正在运行的线程,则会设置==打断标记==;park 的线程被打断,也会设置==打断标记==
interrupted() static 判断当前线程是否被打断 会清除==打断标记==
currentThread() static 获取当前正在执行的线程
sleep(long n) static 让当前执行的线程休眠n毫秒,休眠时让出 cpu 的时间片给其它线程
yield() static 提示线程调度器让出当前线程对CPU的使用 主要是为了测试和调试

6、不推荐的方法

还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁

方法名 static 功能说明
stop() 停止线程运行
suspend() 挂起(暂停)线程运行
resume() 恢复线程运行

7、基础线程机制

1、Executor

Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。

主要有三种 Executor:

  • CachedThreadPool: 一个任务创建一个线程;
  • FixedThreadPool:所有任务只能使用固定大小的线程;
  • SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。

具体使用:(代码)

1
2
3
4
5
6
7
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 5; i++) {
executorService.execute(new MyRunnable());
}
executorService.shutdown();
}

2、Daemon

守护线程是程序运行时在后台提供服务的线程,属于程序中不可或缺的一部分

当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程

main() 属于非守护线程。

使用 setDaemon() 方法将一个线程设置为守护线程。

1
2
3
4
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.setDaemon(true);
}

3、sleep()

Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为==毫秒==。

sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。

1
2
3
4
5
6
7
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
案例——防止CPU占用100%

在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权给其他程序

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestCpu {
public static void main(String[] args) {
new Thread(() -> {
while(true) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
  • 可以用 wait 或 条件变量达到类似的效果
  • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
  • sleep 适用于无需锁同步的场景

4、yield()

对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行(让位操作)。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。

1
2
3
public void run() {
Thread.yield();
}

5、run/start、sleep/yield、线程优先级

1、run与start
1、run

run:称为线程体,包含了要执行的这个线程的内容,方法运行结束,此线程随即终止。直接调用 run 是在主线程中执行了 run,没有启动新的线程,需要顺序执行

2、start

start:使用 start 是启动新的线程,此线程处于就绪(可运行)状态,通过新的线程间接执行 run 中的代码

说明:线程控制资源类

3、面试问题:run() 方法中的异常不能抛出,只能 try/catch
  • 因为父类中没有抛出任何异常,子类不能比父类抛出更多的异常
  • 异常不能跨线程传播回 main() 中,因此必须在本地进行处理
4、run与start之间的区别
  • 直接调用 run 是在主线程中执行了 run,没有启动新的线程,相当于变成了普通类的执行,此时将只有主线程在执行该线程

  • 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码

  • 调用start()方法之前与之后线程的状态:

    • 代码:

      • public class Test5 {
            public static void main(String[] args) {
                Thread t1 = new Thread("t1") {
                    @Override
                    public void run() {
                        log.debug("running...");
                    }
                };
        
                System.out.println(t1.getState());
                t1.start();
                System.out.println(t1.getState());
            }
        }
        
        1
        2
        3
        4
        5
        6
        7

        - 结果:

        - ```sh
        NEW
        RUNNABLE
        12:51:05.298 [t1] c.Test5 - running...
2、sleep与yield之间的区别
1、sleep
  • 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
    • 使用sleep后,线程失去cpu的时间片。同时也不能在获取cpu的时间片。
  1. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  2. 睡眠结束后的线程未必会立刻得到执行
  3. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
2、yield
  • 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
    • 使用yield后,如果线程进入Runnable就绪状态还是有可能签到cpu时间片的,这是与sleep()最大的不同
  1. 具体的实现依赖于操作系统的任务调度器
  2. 会放弃 CPU 资源,锁资源不会释放
3、线程优先级(priority)
  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用

8、线程中断

一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。

1、InterruptedException

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InterruptExample {

private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
1
2
3
4
5
6
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
1
2
3
4
5
6
7
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

2、interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程

1
2
3
4
5
6
7
8
9
10
11
12
public class InterruptExample {

private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
}
1
2
3
4
5
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
1
Thread end

3、Executor 的中断操作

  1. 调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,
  2. 但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。

以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.shutdownNow();
System.out.println("Main run");
}
1
2
3
4
5
6
7
8
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)

如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程

1
2
3
4
Future<?> future = executorService.submit(() -> {
// ..
});
future.cancel(true);

9、线程互斥同步

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问:

  1. 第一个是 JVM 实现的 synchronized;
  2. 而另一个是 JDK 实现的 ReentrantLock。

1、synchronized

1、同步一个代码块
1
2
3
4
5
public void func() {
synchronized (this) {
// ...
}
}

它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步

对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。

1
2
3
4
5
6
7
8
9
10
public class SynchronizedExample {

public void func1() {
synchronized (this) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func1());
executorService.execute(() -> e1.func1());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
2、同步一个方法
1
2
3
public synchronized void func () {
// ...
}

它和同步代码块一样,作用于同一个对象。

3、同步一个类
1
2
3
4
5
public void func() {
synchronized (SynchronizedExample.class) {
// ...
}
}

作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步

1
2
3
4
5
6
7
8
9
10
public class SynchronizedExample {

public void func2() {
synchronized (SynchronizedExample.class) {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
}
}
}
1
2
3
4
5
6
7
public static void main(String[] args) {
SynchronizedExample e1 = new SynchronizedExample();
SynchronizedExample e2 = new SynchronizedExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> e1.func2());
executorService.execute(() -> e2.func2());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
4、同步一个静态方法
1
2
3
public synchronized static void fun() {
// ...
}

作用于整个类。

2、ReentrantLock(JUC中的ReentrantLock)

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class LockExample {

private Lock lock = new ReentrantLock();

public void func() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
} finally {
lock.unlock(); // 确保释放锁,从而避免发生死锁。
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
LockExample lockExample = new LockExample();
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> lockExample.func());
executorService.execute(() -> lockExample.func());
}
1
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

3、比较

  • 锁的实现
    • synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
  • 性能
    • 新版本 Java 对 synchronized 进行了很多优化,例如==自旋锁==等,synchronized 与 ReentrantLock 大致相同。
  • 等待可中断
    • 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情
      • ReentrantLock 可中断
      • 而 synchronized 不行
  • 公平锁
    • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
      • synchronized 中的锁是非公平的
      • ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
  • 锁绑定多个条件
    • 一个 ReentrantLock 可以同时绑定多个 Condition 对象。

4、使用选择

除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized

这是因为:

  1. synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。
  2. 并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。

10、线程之间的协作

当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调

1、join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。

原理:调用者轮询检查线程 alive 状态,t1.join()等价于:原理:调用者轮询检查线程 alive 状态,t1.join()等价于:

1
2
3
4
5
6
synchronized (t1) {
// 调用者线程进入 t1 的 waitSet 等待, 直到 t1 运行结束
while (t1.isAlive()) {
t1.wait(0);
}
}
  • join 方法是被 synchronized 修饰的,本质上是一个对象锁,其内部的 wait 方法调用也是释放锁的,但是释放的是当前线程的对象锁,而不是外面的锁
  • t1 会强占 CPU 资源,直至线程执行结束,当调用某个线程的 join 方法后,该线程抢占到 CPU 资源,就不再释放,直到线程执行完毕

线程同步:

  • join 实现线程同步,因为会阻塞等待另一个线程的结束,才能继续向下运行
    • 需要外部共享变量,不符合面向对象封装的思想
    • 必须等待线程结束,不能配合线程池使用
  • Future 实现(同步):get() 方法阻塞等待执行结果
    • main 线程接收结果
    • get 方法是让调用线程同步等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test {
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
r = 10;
});
t1.start();
t1.join();//不等待线程执行结束,输出的10
System.out.println(r);
}
}
1、为什么需要join()

如果想要某线程(A)优先于某线程(B)运行(场景:线程B需要线程A的运算结果),这个时候就得线程B就需要使用join()来挂起当前线程,直到目标线程(A)结束。

对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class JoinExample {

private class A extends Thread {
@Override
public void run() {
System.out.println("A");
}
}

private class B extends Thread {

private A a;

B(A a) {
this.a = a;
}

@Override
public void run() {
try {
a.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("B");
}
}

public void test() {
A a = new A();
B b = new B(a);
b.start();
a.start();
}
}
1
2
3
4
public static void main(String[] args) {
JoinExample example = new JoinExample();
example.test();
}
1
2
A
B
2、为什么不用sleep(),而使用join()

使用sleep也可以实现以上效果,但是不好:因为在设计情况下你不清楚A线程需要多次时间得到运算结果,所以B线程不知道要sleep多少时间。

3、join(long)

join(long)可以设置等待时间,单位是ms。

  • 如果到了设置的时间还没有结果,线程会结束等待,继续往下运行
  • 如果在设置的时间之前就应经有结果了,线程会立即往下运行,不会等到设定的时间
4、join的底层原理——保护性暂停模式的时间增强

先看一下join的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public final void join() throws InterruptedException {
join(0);
}

public final synchronized void join(long millis)
throws InterruptedException {
// 开始时间
long base = System.currentTimeMillis();
// 经历时间
long now = 0;

// 相关判断
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}

if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
// 延迟时间,相当于保护性暂停中的waitTime
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}

可以将join的底层实现与保护性暂停模式的时间增强进行对比,会发现join的底层用的是保护性暂停模式的时间增强

2、wait()、notify()、notifyAll()

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待

    • wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止

    • wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify

    • 其实还有一个wait(long timeout, int nanos)方法,只是这个方法是一个无效方法:它的意思是可以把时间精确到纳秒,而实际上无论你在第二个参数填写什么值(大于0小于999999),他都只是将第一个参数的值加一

    • public final void wait(long timeout, int nanos) throws InterruptedException {
          if (timeout < 0) {
              throw new IllegalArgumentException("timeout value is negative");
          }
      
          if (nanos < 0 || nanos > 999999) {
              throw new IllegalArgumentException(
                  "nanosecond timeout value out of range");
          }
      
          if (nanos > 0) {
              timeout++;
          }
      
          wait(timeout);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27

      - `obj.notify()` 在 object 上正在 waitSet 等待的线程中挑一个唤醒

      - `obj.notifyAll()` 让 object 上正在 waitSet 等待的线程全部唤醒

      **它们都属于 Object 的一部分,而不属于 Thread**。

      **==只能用在同步方法或者同步控制块中使用==**,否则会在运行时抛出 `IllegalMonitorStateExeception`。也侧面说明了wait/notify只能用在重量级锁。

      **使用 wait() 挂起期间,线程会释放锁**。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

      ```java
      public class WaitNotifyExample {
      public synchronized void before() {
      System.out.println("before");
      notifyAll();
      }

      public synchronized void after() {
      try {
      wait();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("after");
      }
      }
1
2
3
4
5
6
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after
1、wait() 和 sleep() 的区别
  • sleep 是 Thread 的静态方法,wait 是 Object 的方法,任何对象实例都能调用
  • **sleep 不会释放锁,它也不需要占用锁。wait 会释放锁,但调用它的前提是当前线程占有锁(即代码要在 synchronized 中)**。
  • sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
2、wait() 和 sleep() 的共同点
  • 它们都可以被 interrupted 方法中断
  • 它们状态 TIMED_WAITING
  • 在哪里睡着,就在哪里醒来

3、await()、signal()、signalAll()

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活

使用 Lock 来获取一个 Condition 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AwaitSignalExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

public void before() {
lock.lock();
try {
System.out.println("before");
condition.signalAll();
} finally {
lock.unlock();
}
}

public void after() {
lock.lock();
try {
condition.await();
System.out.println("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
1
2
3
4
5
6
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
1
2
before
after

4、interrupt

1、打断 sleep,wait,join 的线程

sleep,wait,join、这几个方法都会让线程进入阻塞状态(join底层就是wait,其实join与wait本质上是一样的)

可以使用interrupt方法来打断线程:

  • 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
  • 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
    • 对于Running的线程,也就是正常运行的线程被打断(interrupt)后,不会立刻中断它,而是将其的打断标记isInterrupted()设置为true,可以在正常运行的线程中通过这个打断标记来选择是否终止自身线程。
    • 也就是说:因为直接把线程终结了,人家线程事情都没干完。不如跟他说一声,说我要打断你,他处理完事情后自行了断不更好
2、多线程设计模式——两阶段终止

详细请看——并发的相关多线程设计模式

3、打断 park 线程

打断 park 线程,不会清空打断状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
test3();
}

private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();

sleep(1);
t1.interrupt();
}

输出:

1
2
3
21:11:19.373 [t1] c.TestInterrupt - park... 
21:11:20.371 [t1] c.TestInterrupt - unpark...
21:11:20.371 [t1] c.TestInterrupt - 打断状态:true

如果打断标记已经是 true,则 park 会失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
test3();
}

private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
LockSupport.park();
log.debug("unpark...");
}, "t1");
t1.start();

sleep(1);
t1.interrupt();
}

输出:

1
2
3
4
21:11:54.003 [t1] c.TestInterrupt - park... 
21:11:55.002 [t1] c.TestInterrupt - unpark...
21:11:55.002 [t1] c.TestInterrupt - 打断状态:true
21:11:55.005 [t1] c.TestInterrupt - unpark...

提示:可以使用 Thread.interrupted() 清除打断状态

1
log.debug("打断状态:{}", Thread.currentThread().interrupted());

4、关键字:synchronized详解

在C程序代码中我们可以利用操作系统提供的互斥锁来实现同步块的互斥访问及线程的阻塞及唤醒等工作。在Java中除了提供Lock API外还在语法层面上提供了synchronized关键字来实现互斥同步原语。

1、BAT大厂的面试问题

  • Synchronized可以作用在哪里?分别通过对象锁和类锁进行举例。
  • Synchronized本质上是通过什么保证线程安全的?
    • 分三个方面回答:
      • 加锁和释放锁的原理
      • 可重入原理
      • 保证可见性原理
  • Synchronized有什么样的缺陷?Java Lock是怎么弥补这些缺陷的?
  • Synchronized和Lock的对比,和选择?
  • Synchronized在使用时有何注意事项?
  • Synchronized修饰的方法在抛出异常时,会释放锁吗?
  • 多个线程等待同一个snchronized锁的时候,JVM如何选择下一个获取锁的线程?
  • Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?
  • 我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?
  • 什么是锁的升级和降级?什么是JVM里的偏斜锁、轻量级锁、重量级锁?
  • 不同的JDK中对Synchronized有何优化?

2、Synchronized的使用

在应用Sychronized关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响
  • 例外:*锁对象是.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁**。
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会==释放锁==

1、对象锁

包括==方法锁==(默认锁对象为this当前实例对象)和==同步代码块锁==(自己指定锁对象)

1、代码块形式

手动指定锁定对象:

  • 可以是this:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence = new SynchronizedObjectLock();

    @Override
    public void run() {
    // 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
    synchronized (this) {
    System.out.println("我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println(Thread.currentThread().getName() + "结束");
    }
    }

    public static void main(String[] args) {
    Thread t1 = new Thread(instence);
    Thread t2 = new Thread(instence);
    t1.start();
    t2.start();
    }
    }
    1
    2
    3
    4
    我是线程Thread-0
    Thread-0结束
    我是线程Thread-1
    Thread-1结束
  • 也可以是自定义的锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence = new SynchronizedObjectLock();
    // 创建2把锁
    Object block1 = new Object();
    Object block2 = new Object();

    @Override
    public void run() {
    // 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
    synchronized (block1) {
    System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
    }

    synchronized (block2) {
    System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
    try {
    Thread.sleep(3000);
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
    }
    }

    public static void main(String[] args) {
    Thread t1 = new Thread(instence);
    Thread t2 = new Thread(instence);
    t1.start();
    t2.start();
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    block1锁,我是线程Thread-0
    block1锁,Thread-0结束
    block2锁,我是线程Thread-0  // 可以看到当第一个线程在执行完第一段同步代码块之后,第二个同步代码块可以马上得到执行,因为他们使用的锁不是同一把
    block1锁,我是线程Thread-1
    block2锁,Thread-0结束
    block1锁,Thread-1结束
    block2锁,我是线程Thread-1
    block2锁,Thread-1结束
2、方法锁形式:synchronized修饰普通方法,锁对象默认为this
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
Thread t1 = new Thread(instence);
Thread t2 = new Thread(instence);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

2、类锁

指synchronize修饰静态的方法或指定锁对象为Class对象。

1、synchronize修饰静态方法

synchronize修饰普通方法与修饰静态方法的区别:

  • synchronized用在普通方法上,默认的锁就是this,当前实例
  • synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把

修饰普通方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

// synchronized用在普通方法上,默认的锁就是this,当前实例
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
// t1和t2对应的this是两个不同的实例,所以代码不会串行
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
我是线程Thread-1
Thread-1结束
Thread-0结束

修饰静态方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
method();
}

// synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}

public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束
2、synchronized指定锁对象为Class对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

@Override
public void run() {
// 所有线程需要的锁都是同一把
synchronized(SynchronizedObjectLock.class){
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}

public static void main(String[] args) {
Thread t1 = new Thread(instence1);
Thread t2 = new Thread(instence2);
t1.start();
t2.start();
}
}
1
2
3
4
我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

3、关于synchronized锁的总结

对于Synchronized实现同步的基础:java中每一个对象都可以作为锁。

具体可以分为以下三种情况:

  • 对于普通同步方法,锁是当前实例对象;(对象锁)
  • 对于静态同步方法,锁是当前类的Class 对象;(类锁)
  • 对于同步方法块,锁是Synchonized 括号里配置的对象

对于对象锁:

  • 如果一个实例对象非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁;
  • 别的实例对象的非静态同步方法因为跟该实例对象的非静态同步方法用的是不同的锁, 所以无须等待该实例对象已获取锁的非静态同步方法释放锁就可以获取他们自己的锁;
  • 每一个对象都有属于自己的对象锁(可以有多把对象锁)

对于类锁:

  • 所有的静态同步方法用的也是同一把锁——类对象本身(类锁),这与对象锁是两个不同的对象,所以静态同步方法与非静态同步方法之间是不会有竞态条件的;
  • 一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁;
  • 但不管是同一个实例对象的静态同步方法之间,还是不同的实例对象的静态同步方法之间,只要它们同一个类的实例对象
  • 类锁只有一把

对于同步代码块:

  • 同步代码块的锁是Synchonized 括号里配置的对象;
  • 如果Synchonized 括号里是对象,那么他就是对象锁;如果Synchonized 括号里是类,那么他就是类锁;
  • 所以他可以有一把,也可以有多把(主要看如果Synchonized 括号里是类还是对象)

举个例子:把synchronized的锁看成一座大楼

  • 类锁就是锁住大楼的锁
  • 对象锁就是锁住大楼里面房间的锁,每一个房间都有属于它的一把锁

3、Synchronized的原理分析

1、加锁和释放锁的原理

现象、时机(内置锁this)、深入JVM看字节码(反编译看monitor指令)

  1. 深入JVM看字节码,创建如下的代码:

    1
    2
    3
    4
    5
    6
    7
    8
    public class SynchronizedDemo2{
    static final Object lock = new Object(); static int counter = 0;
    public static void main(String[] args) {
    synchronized (lock) {
    counter++;
    }
    }
    }
  2. 使用javac命令进行编译生成.class文件

    1
    >javac SynchronizedDemo2.java
  3. 使用javap命令反编译查看.class文件的信息

    1
    >javap -verbose SynchronizedDemo2.class
  4. 得到如下的信息:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    Code:
    stack=2,locals=3,args_size=1
    0: getstatic #2 // <- lock引用( synchronized开始)
    3: dup
    4: astore_1 // lock引用 -> slot 1
    5: monitorenter // 将lock对象 MarkWord 置为 Monitor 指针
    6: getstatic #3 // <- i
    9: iconst_1 // 准备常数1
    10: iadd // +1
    11: putstatic #3 // -> i
    14: aload_1 // <- lock引用
    15: monitorexit // 将lock对象 MarkWord 重置,唤醒EntryList
    16: goto 24
    19: astore_2 // e -> slot2
    20: aload_1 // <- lock引用
    21: monitorexit // 将 lock 对象 MarkWord 重置,唤醒EntryList
    22: aload_2 // <- slot 2 (e)
    23: athrow // throw e
    24: return
    Exception table:
    from to target type
    6 16 19 any
    19 22 19 any
    LineNumberTable:
    line 8: 0
    line 9: 6
    line 10: 14
    line 11: 24
    LocalVar iableTable:
    Start Length Slot Name Signature
    0 25 0 args [Ljava/lang/String;
    StackMapTable: number_of_entries = 2
    frame_type = 255 /* full_ _frame */
    offset_delta = 19
    locals = [ class "[Ljava/lang/String;", class java/lang/object ]
    stack = [ class java/lang/Throwable ]
    frame_type = 250 /* chop */
    offset_delta = 4

注意:

  • 方法级别的 synchronized 不会在字节码指令中有所体现
  • 在字节码中的16: goto 24当中,执行到这里会跳转到第24行的字节码执行24:return返回
  • 那么第19行到第23行的字节码的作用是什么?
    • 仔细阅读字节码的内容会发现:他们的作用是当同步代码块中的内容出现异常的时候,为了防止当前的锁得不到释放而造成死锁,在第19到第23行进行异常的抛出锁的释放

关注字节码当中的monitorentermonitorexit即可。

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

img

该图可以看出,任意线程对Object的访问,首先要获得Object的监视器monitor,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

2、可重入原理:加锁次数计数器

上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗? 答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁

Synchronized先天具有重入性。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。

3、保证可见性的原理:内存模型和happens-before规则

Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。继续来看代码:

1
2
3
4
5
6
7
8
9
10
11
public class MonitorDemo {
private int a = 0;

public synchronized void writer() { // 1
a++; // 2
} // 3

public synchronized void reader() { // 4
int i = a; // 5
} // 6
}

该代码的happens-before关系如图所示:

img

在图中每一个箭头连接的两个节点就代表之间的happens-before关系:

  • 黑色的是通过程序顺序规则推导出来,
  • 红色的为监视器锁规则推导而出:
    • 线程A释放锁happens-before线程B加锁;
  • 蓝色的则是通过程序顺序规则和监视器锁规则推测出来happens-befor关系,通过传递性规则进一步推导的happens-before关系。
    • 现在我们来重点关注:2 happens-before 5,通过这个关系我们可以得出什么?
      • 根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。线程A先对共享变量A进行加一,由2 happens-before 5关系可知线程A的执行结果对线程B可见即线程B所读取到的a的值为1。

4、synchronized 是给对象加锁的原理——对象的对象头

synchronized 对对象进行加锁,在 JVM 中,对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:
    • Mark Word(标记字段)
    • Klass Pointer(类型指针)

以 32 位虚拟机为例:

普通对象:

1
2
3
4
5
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|

数组对象:

1
2
3
4
5
|---------------------------------------------------------------------------------| 
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|

其中 Mark Word 结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
|-------------------------------------------------------|--------------------| 
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|

64 位虚拟机 Mark Word:

1
2
3
|--------------------------------------------------------------------|--------------------| |                        Mark Word (64 bits)                         |       State        | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       | |--------------------------------------------------------------------|--------------------| |             ptr_to_lock_record:62                          | 00    | Lightweight Locked | |--------------------------------------------------------------------|--------------------| |             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked | |--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

Monitor 被翻译为监视器管程

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针,如下图所示,右侧就是对象对应的 Monitor 对象。

图片

当 Monitor 被某个线程持有后,就会处于锁定状态,如图中的 Owner 部分,会指向持有 Monitor 对象的线程。

另外 Monitor 中还有两个队列分别是EntryListWaitList,主要是用来存放进入及等待获取锁的线程

如果线程进入,则得到当前对象锁,那么别的线程在该类所有对象上的任何操作都不能进行。

  • Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
  • Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Monitor 结构如下

image-20210804223435390

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程
  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

在对象级使用锁通常是一种比较粗糙的方法,为什么要将整个对象都上锁,而不允许其他线程短暂地使用对象中其他同步方法来访问共享资源?

如果一个对象拥有多个资源,就不需要只为了让一个线程使用其中一部分资源,就将所有线程都锁在外面。

由于每个对象都有锁,可以如下所示使用虚拟对象来上锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FineGrainLock{
MyMemberClass x,y;
Object xlock = new Object(), ylock = newObject();
public void foo(){
synchronized(xlock){
//accessxhere
}
//dosomethinghere-butdon'tusesharedresources
synchronized(ylock){
//accessyhere
}
}
public void bar(){
synchronized(this){
//accessbothxandyhere
}
//dosomethinghere-butdon'tusesharedresources
}
}

4、JVM中锁的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)下,如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁
  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地Stack上进行对象空间的分配(栈上分配)(同时还可以减少Heap上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning)当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态

1、锁的类型

在Java SE 1.6里Synchronied同步锁,一共有四种状态:无锁偏向锁轻量级所重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

2、自旋锁与自适应自旋锁

1、自旋锁

引入背景:

大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但==不放弃CPU的执行时间==。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。

自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

自旋重试成功的情况:

线程1 (core1上) 对象Mark 线程2 ( core2上)
- 10(重量锁) -
访问同步块,获取monitor 10 (重量锁)重量锁指针 -
成功(加锁) 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 访问同步块,获取monitor
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行完毕 10 (重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10 (重量锁)重量锁指针 成功(加锁)
- 10 (重量锁)重量锁指针 执行同步块
- …… ……

自旋重试失败的情况:

线程1 (core1上) 对象Mark 线程2 ( core2上)
- 10(重量锁) -
访问同步块,获取monitor 10 (重量锁)重量锁指针 -
成功(加锁) 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 -
执行同步块 10 (重量锁)重量锁指针 访问同步块,获取monitor
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 自旋重试
执行同步块 10 (重量锁)重量锁指针 阻塞
- …… ……

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

2、自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。

总结:

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

3、锁消除

锁消除时指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。

当然在实际开发中,我们很清楚的知道那些地方时线程独有的,不需要加同步锁,但是在Java API中有很多方法都是加了同步的,那么此时JVM会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

比如如下操作:在操作String类型数据时,由于String是一个不可变类,对字符串的连接操作总是通过生成的新的String对象来进行的。因此Javac编译器会对String连接做自动优化。在JDK 1.5之前会使用StringBuffer对象(线程安全)的连续append()操作,在JDK 1.5及以后的版本中,会转化为StringBuidler对象(线程不安全)的连续append()操作。

1
2
3
4
public static String test03(String s1, String s2, String s3) {
String s = s1 + s2 + s3;
return s;
}

对上述代码使用javap 编译的结果:

img

众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

4、锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。

这里贴上根据上述Javap 编译地情况编写的实例java类

1
2
3
4
5
6
7
public static String test04(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的外部,使整个一连串地append()操作只需要加锁一次就可以了。

5、轻量级锁

在JDK 1.6之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现提出的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能

如果要理解轻量级锁,那么必须先要了解HotSpot虚拟机中对象头的内存布局。在对象头中(Object Header)存在两部分:(对象头的大小:(压缩指针)12字节,(不支持压缩指针)16字节)

  1. 第一部分用于存储对象自身的运行时数据HashCodeGC Age锁标记位是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word它是实现轻量级锁和偏向锁的关键
  2. 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度
轻量级锁加锁

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(JVM会将对象头中的Mark Word拷贝到锁记录中,官方称为Displaced Mark Ward)这个时候线程堆栈与对象头的状态如图:

img

image-20210804232649899

如上图所示:如果当前对象没有被锁定,那么锁标志位为01状态,JVM在执行当前线程时,首先会在当前线程栈帧中创建锁记录Lock Record的空间用于存储锁对象目前的Mark Word的拷贝。

image-20210804233935482

然后,虚拟机使用CAS操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新为指向Lock Record的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态,如图:

img

image-20210804233158129

如果这个更新操作失败:

  • JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。(可重入锁)
    • image-20210804233645213
  • 如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那**轻量级锁就不再有效,直接膨胀为重量级锁(锁膨胀)**,没有获得锁的线程会被阻塞。此时,锁的标志位为10Mark Word中存储的时指向重量级锁的指针。

轻量级解锁时:

  • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
    • image-20210804234421675
  • 锁记录的值不为 null,这时会使用原子的CAS操作将Displaced Mark Word替换回到对象头中:
    • 如果成功,则表示没有发生竞争关系,解锁成功
    • 如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁,进入重量级锁的解锁流程

6、锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

1
2
3
4
5
6
static Object obj = new Object(); 
public static void method1() {
synchronized( obj ) {
// 同步块
}
}
  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    • image-20210805013827060
  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    • 然后自己进入 Monitor 的 EntryList BLOCKED
    • image-20210805013917321
    • 此时Object的对象头的锁标志为10
  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

两个线程同时争夺锁,导致锁膨胀的流程图如下:

img

7、偏向锁

引入背景:

在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换

为了解决这一问题,HotSpot的作者在Java SE 1.6 中对Synchronized进行了优化,引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁。只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁

1、偏向状态

64 位虚拟机 Mark Word:

1
2
3
|--------------------------------------------------------------------|--------------------| |                        Mark Word (64 bits)                         |       State        | |--------------------------------------------------------------------|--------------------| | unused:25 | hashcode:31 | unused:1 | age:4 | biased_lock:0 | 01    |       Normal       | |--------------------------------------------------------------------|--------------------| | thread:54 | epoch:2     | unused:1 | age:4 | biased_lock:1 | 01    |       Biased       | |--------------------------------------------------------------------|--------------------| |             ptr_to_lock_record:62                          | 00    | Lightweight Locked | |--------------------------------------------------------------------|--------------------| |             ptr_to_heavyweight_monitor:62                  | 10    | Heavyweight Locked | |--------------------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|--------------------------------------------------------------------|--------------------|

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
  • 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
    • 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中(54位的threadID)
  • 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
  • 如果你想禁用偏向锁,添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁

img

2、偏向锁的撤销
1、方法一:调用对象的hashCode方法(对象仍可偏向)
  • 如果默认开启了偏向锁,但当调用了对象的hashCode方法则会破坏对象的偏向锁
    • 正常状态对象一开始是没有 hashCode 的,第一次调用才生成
    • 调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
      • 轻量级锁会在锁记录中记录 hashCode
      • 重量级锁会在 Monitor 中记录 hashCode
      • 偏向锁没有其它记录hashCode的方法,所以调用对象的hashCode会撤销对象的偏向锁
    • 在调用 hashCode 后使用偏向锁,记得去掉 -XX:-UseBiasedLocking(禁用偏向锁)
2、方法二:当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(对象变为不可偏向)

演示代码:(加上了VM参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Slf4j(topic = "c.TestBiased")
public class TestBiased {
public static void main(String[] args) throws InterruptedException {
Dog d = new Dog();

new Thread(() -> {
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (TestBiased.class) {
TestBiased.class.notify();
}
},"t1").start();

new Thread(() -> {
synchronized (TestBiased.class) {
try {
TestBiased.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug( ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(ClassLayout.parseInstance(d).toPrintableSimple(true));
},"t2").start();
}
}

class Dog {

}

输出:

1
2
3
4
5
6
20:48:31.674 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
20:48:31.677 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.677 c.TestBiased [t1] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.677 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00011111 10110110 11101000 00000101
20:48:31.678 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00100000 01001011 11110011 00100000
20:48:31.678 c.TestBiased [t2] - 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001

分析:

  • 由于t2线程使用了wait,所以t2需要t1线程的notify唤醒,所以t1线程肯定由于t2线程,得到偏向锁。然后唤醒t2线程后,t2线程去争夺锁,导致了t1线程的偏向锁的破坏,并且t1线程变为不可偏向。
  • 第一行:由于禁用延迟,所以t1线程一开始就处于101的偏向锁,只是此时t1线程还没得到锁,所以它的 thread、epoch、age 都为 0
  • 第二行:t1线程拿到了锁,Mark Word记录了当前线程的ThreadID(54位)、epoch(2位)、unused(1位)和age(4位)
  • 第三行:t1线程释放了锁,由于t1线程为偏向锁,所以Mark Word依旧记录了t1线程的ThreadID(54位)
  • 递四行:t1线程唤醒了t2线程,当此时t2x线程还没有抢夺t1线程的偏向锁,所以Mark Word没变
  • 第五行:t2线程抢夺t1的偏向锁,破坏了t1线程的偏向锁,偏向锁膨胀为轻量级锁(Mark Word后三位为000
    • 此时Mark Word记录的是ptr_to_lock_record:62
  • 第六行:t2线程释放锁,Mark Word后三位为001

底层:

偏向锁使用了一种==等待竞争出现才会释放锁==的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放偏向锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁

img

3、调用 wait/notify

因为 wait/notify(等待唤醒)模式是应用在重量级锁上的,所以调用 wait/notify就意味着此时是重量级锁,而不是偏向锁与轻量级锁。

3、批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID

当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private static void test3() throws InterruptedException {
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
synchronized (list) {
list.notify();
}
},"t1");
t1.start();

Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
log.debug("===============> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
},"t2");
t2.start();
}

class Dog {

}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
[t1] - 0 		00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 1 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 2 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 3 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 4 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 5 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 6 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 7 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 8 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 9 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 10 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 11 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 12 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 13 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 14 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 15 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 16 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 17 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 18 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t1] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - ===============>
[t2] - 0 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 0 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 0 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 1 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 1 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 2 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 2 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 2 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 3 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 3 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 3 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 4 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 4 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 4 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 5 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 5 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 5 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 6 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 6 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 6 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 7 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 7 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 7 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 8 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 8 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 8 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 9 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 9 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 9 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 10 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 10 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 10 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 11 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 11 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 11 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 12 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 12 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 12 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 13 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 13 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 13 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 14 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 14 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 14 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 15 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 15 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 15 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 16 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 16 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 16 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 17 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 17 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 17 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 18 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 18 00000000 00000000 00000000 00000000 00100000 01011000 11110111 00000000
[t2] - 18 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 20 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 21 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 22 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 23 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 24 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 25 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 26 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 27 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 28 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 29 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101

注意:

1
2
3
[t2] - 19		00000000 00000000 00000000 00000000 00011111 11110011 11100000 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101
[t2] - 19 00000000 00000000 00000000 00000000 00011111 11110011 11110001 00000101

在第20次(从0开始,到19)后,批量重偏向

4、批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import lombok.extern.slf4j.Slf4j;
import org.openjdk.jol.info.ClassLayout;

import java.util.Vector;
import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.TestBiased")
public class TestBiased {

static Thread t1,t2,t3;

public static void main(String[] args) throws InterruptedException {
test4();
}
private static void test4() throws InterruptedException {
Vector<Dog> list = new Vector<>();

int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}
LockSupport.unpark(t2);
}, "t1");
t1.start();

t2 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
LockSupport.unpark(t3);
}, "t2");
t2.start();

t3 = new Thread(() -> {
LockSupport.park();
log.debug("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
synchronized (d) {
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
log.debug(i + "\t" + ClassLayout.parseInstance(d).toPrintableSimple(true));
}
}, "t3");
t3.start();

t3.join();
log.debug(ClassLayout.parseInstance(new Dog()).toPrintableSimple(true));
}
}

class Dog {

}

输出:

  • t1线程前面的39个对象全部拥有了偏向锁
    • image-20210805043937707
  • t2线程前19次因为破坏了t1线程对象的偏向锁,升级为轻量级锁
    • image-20210805044214898
  • t2线程从第20次后进入批量重偏向,从第20次到第39次全部都是批量重偏向,t2线程拥有偏向锁
    • image-20210805044307591
  • t3线程的前19个对象为轻量级锁(t2修改为轻量级锁)
    • image-20210805044616494
  • t3线程从第20个对象开始,此时对象的偏向锁是偏向t2线程的,所以t3线程会破坏t2线程的偏向锁,升级为轻量级锁,从第20个到第39个都是这样。
    • image-20210805044906263
  • 由于JVM进行了前39次的偏向锁撤销,在进行第40次撤销操作时,JVM会将整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
    • image-20210805045205698
  • 如果把loopNumber的值修改为38,即只进行38次偏向锁撤销,那么在第39次偏向锁撤销,JVM依旧会采用偏向锁升级为轻量级锁,此时的对象依旧是可偏向的(101
    • image-20210805045456551

8、锁的优缺点对比

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步快的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步快执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步快执行速度较长

5、Synchronized与Lock

1、synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时;
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,….,如果获取失败,…..
  • 如果一个代码块被 synchronized 修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
    • 获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
    • 线程执行发生异常,此时 JVM 会让线程自动释放锁。
  • 那么如果这个获取锁的线程由于要等待 I/O 或者其他原因(比如调用 sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能干巴巴地等待,试想一下,这多么影响程序执行效率。

2、Lock解决相应问题

Lock类这里不做过多解释,主要看里面的4个方法:

  • lock():加锁
  • unlock():解锁
  • tryLock():尝试获取锁,返回一个boolean值
  • tryLock(long,TimeUtil):尝试获取锁,可以设置超时

Synchronized只有锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。

Lock可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断)。

ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了

注:

  • ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大
  • JUC中的JUC锁:ReentrantLock

3、总结:Lock 与的 Synchronized 区别

  • Lock 不是 Java 语言内置的,synchronized 是 Java 语言的关键字,因此是内置特性。Lock 是一个类,通过这个类可以实现同步访问;
  • synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁;
  • Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用synchronized 时,等待的线程会一直等待下去,不能够响应中断;
  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到;
  • Lock 可以提高多个线程进行读操作的效率。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时 Lock 的性能要远远优于synchronized

6、再深入理解

synchronized是通过软件(JVM)实现的,简单易用,即使在JDK5之后有了Lock,仍然被广泛地使用。

  • 使用Synchronized有哪些要注意的?
    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
  • synchronized是公平锁吗?
    • synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待
    • 不过这种抢占的方式可以预防饥饿
  • 使用Synchronized可以解决可见性问题吗?
    • 可以在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存→在主内存中拷贝最新变量的副本到工作内存 →执行完代码→将更改后的共享变量的值刷新到主内存中→释放互斥锁。

5、关键字:volatile详解

相比Sychronized(重量级锁,对系统性能影响较大),volatile提供了另一种解决可见性和有序性问题的方案。

1、BAT大厂的面试问题

  • volatile关键字的作用是什么?
  • volatile能保证原子性吗?
  • 之前32位机器上共享的long和double变量的为什么要用volatile?现在64位机器上是否也要设置呢?
  • i++为什么不能保证原子性?
  • volatile是如何实现可见性的?
    • 内存屏障
  • volatile是如何实现有序性的?
    • happens-before等
  • 说下volatile的应用场景?

2、volatile的作用详解

1、防重排序

我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
// 向外界通过一个getInstance()方法
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。
  • 初始化对象。
  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

  • 分配内存空间。
  • 将内存空间的地址赋值给对应的引用。
  • 初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

注意:加上volatile的变量会保证在它之前的指令不会被重排序。原因:在加上volatile的变量的地方会加上一个内存屏障,保证在它之前的指令不会重排序到它下面去。

2、实现可见性

可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下面的例子,就可以知道其作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class VolatileTest {
int a = 1;
int b = 2;

public void change(){
a = 3;
b = a;
}

public void print(){
System.out.println("b=" + b + ";a=" + a);
}

public static void main(String[] args) {
while (true){
final VolatileTest test = new VolatileTest();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}

直观上说,这段代码的结果只可能有两种:

  1. b=3;a=3
  2. b=2;a=1

不过运行上面的代码(可能时间上要长一点,概率要小很多),你会发现除了上两种结果之外,还出现了第三种结果:

  • b=3;a=1

分析:为什么会出现b=3;a=1这种结果呢?

  • 正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。
  • 相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。
  • 那b=3;a=1的结果是怎么出来的?
    • 原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。
    • 如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。

3、保证原子性:单次读/写

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性

先从如下两个问题来理解(后文再从内存屏障的角度理解):

  1. 问题1: i++为什么不能保证原子性?
  2. 问题2: 共享的long和double变量的为什么要用volatile?
1、问题1: i++为什么不能保证原子性?

对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。

现在我们就通过下列程序来演示一下这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class VolatileTest01 {
volatile int i;

public void addI(){
i++;
}

public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}

大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:981 可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:

  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。

i++的相关字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
iadd //自增
putstatic i // 将修改后的值存入静态变量i

对于i–也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
isub //自减
putstatic i // 将修改后的值存入静态变量i

volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。

2、问题2: 共享的long和double变量的为什么要用volatile?

因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。

如下是JLS中的解释:

17.7 Non-Atomic Treatment of double and long

  • For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.
  • Writes and reads of volatile long and double values are always atomic.
  • Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.
  • Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.
  • Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。

3、volatile的实现原理

1、volatile 可见性实现

volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现

  • 内存屏障,又称内存栅栏,是一个 CPU 指令。
  • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入==特定类型的内存屏障来禁止==+ ==特定类型的编译器重排序和处理器重排序==**,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序**。
  • 对 volatile 变量的写指令后会加入写屏障
    • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 对 volatile 变量的读指令前会加入读屏障
    • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

int num = 0;
volatile boolean ready = false;

public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready){
r1 = num + num;
} else {
r.r1 = 1;
}
}

public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
}

image-20210806223239737

写一段简单的 Java 代码,声明一个 volatile 变量,并赋值。

1
2
3
4
5
6
7
8
9
10
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}

通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......

lock 前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值

volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值

1、lock 指令

在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证

2、缓存一致性

缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节

LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效

2、volatile 有序性实现

1、volatile 的 happens-before 关系

happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; // 1 线程A修改共享变量
flag = true; // 2 线程A写volatile变量
}

public void reader() {
if (flag) { // 3 线程B读同一个volatile变量
int i = a; // 4 线程B读共享变量
……
}
}
}

根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。

  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。
  • 根据 volatile 规则:2 happens-before 3。
  • 根据 happens-before 的传递性规则:1 happens-before 4。

img

因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。

2、volatile 禁止重排序

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序

Java 编译器会在==生成指令系列==时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序

JMM 会针对编译器制定 volatile 重排序规则表:

img

“ NO “ 表示禁止重排序。

为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
内存屏障 说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。

img

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;

@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

int num = 0;
volatile boolean ready = false;

public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready){
r1 = num + num;
} else {
r.r1 = 1;
}
}

public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
}

image-20210806223239737

还是那句话,不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

image-20210806224057799

3、double-checked locking 问题

以著名的 double-checked locking 单例模式为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有
synchronized synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0: getstatic    	#2		// Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中:

  • 17 表示创建对象,将对象引用入栈 // new Singleton
  • 20 表示复制一份对象引用 // 引用地址
  • 21 表示利用一个对象引用,调用构造方法
  • 24 表示利用一个对象引用,赋值给 static INSTANCE

也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

image-20210806224747694

关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

可能有人注意到:在synchronized内部的变量不是可以保证原子性、有序性和可见性吗?为什么21与24还会被重排序?

  • 被synchronized完全接管的变量确实可以保证原子性、有序性和可见性,但是必须是被synchronized完全接管的变量;
  • 在代码上INSTANCE并没有被synchronized完全接管,线程在synchronized内部使用INSTANCE的时候,在synchronized外部还是可能有其它线程接触INSTANCE
  • 所以在synchronized内部,INSTANCE还是有可能被重排序(24与21重排序)

解决方法:对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

4、double-checked locking 解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Singleton {
private Singleton() {}
private volatile static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有
synchronized synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}

字节码上看不出来 volatile 指令的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 // -------------------------------------> 加入对 INSTANCE 变量的读屏障
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter // -----------------------> 保证原子性、可见性
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
// -------------------------------------> 加入对 INSTANCE 变量的写屏障
27: aload_0
28: monitorexit // ------------------------> 保证原子性、可见性
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

  1. 可见性
    • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
    • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
  2. 有序性
    • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
    • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
  3. 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

image-20210806225125974

4、volatile的应用场景

使用 volatile 必须具备的条件

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其他变量的不变式中
  • 只有在状态真正独立于程序内其他内容时才能使用 volatile

1、模式1:状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机

1
2
3
4
5
6
7
8
volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}

2、模式2:一次性安全发布(one-time safe publication)

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;

public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}

public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}

3、模式3:独立观察(independent observation)

安全使用 volatile 的另一种简单模式是定期发布观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserManager {
public volatile String lastUser;

public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}

4、模式4:volatile bean 模式

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@ThreadSafe
public class Person {
private volatile String firstName;
private volatile String lastName;
private volatile int age;

public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }

public void setFirstName(String firstName) {
this.firstName = firstName;
}

public void setLastName(String lastName) {
this.lastName = lastName;
}

public void setAge(int age) {
this.age = age;
}
}

5、模式5:开销较低的读-写锁策略

volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销

1
2
3
4
5
6
7
8
9
10
11
12
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;

public int getValue() { return value; }

public synchronized int increment() {
return value++;
}
}

6、模式6:双重检查(double-checked)

单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。


6、关键字:final详解

1、BAT大厂的面试问题

  • 所有的final修饰的字段都是编译期常量吗?
  • 如何理解private所修饰的方法是隐式的final?
  • 说说final类型的类如何拓展?比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
  • final方法可以被重载吗?
    • 可以
  • 父类的final方法能不能够被子类重写?
    • 不可以
  • 说说final域重排序规则?
  • 说说final的原理?
  • 使用 final 的限制条件和局限性?
  • 看本文最后的一个思考题

2、final基础使用

1、修饰类

当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的

注意:final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字是没有任何意义的

那么final类型的类如何拓展?

  • 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?

设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合,如下代码大概写个组合实现的意思:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* @pdai
*/
class MyString{

private String innerString;

// ...init & other methods

// 支持老的方法
public int length(){
return innerString.length(); // 通过innerString调用老的方法
}

// 添加新方法
public String toMyString(){
//...
}
}

2、修饰方法

  • private 方法是隐式的final

  • final方法是可以被重载的

1、private final

类中所有private方法都隐式地指定为final的,由于无法取用private方法,所以也就不能覆盖它。可以对private方法增添final关键字,但这样做并没有什么好处

看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Base {
private void test() {
}
}

public class Son extends Base{
public void test() {
}
public static void main(String[] args) {
Son son = new Son();
Base father = son;
//father.test();
}
}

Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()是不可执行的,因为Base中的test方法是private的,无法被访问到。

2、final方法是可以被重载的

我们知道父类的final方法是不能够被子类重写的,那么final方法可以被重载吗?

答案是可以的,下面代码是正确的。

1
2
3
4
5
6
7
public class FinalExampleParent {
public final void test() {
}

public final void test(String str) {
}
}

3、修饰参数

Java允许在参数列表中以声明的方式将参数指明为final,这意味这你无法在方法中更改参数引用所指向的对象

这个特性主要用来向匿名内部类传递数据

4、修饰变量

1、所有final修饰的字段都是编译器常量吗?

现在来看编译期常量和非编译期常量,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test {
//编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
//非编译期常量
Random r = new Random();
final int k = r.nextInt();

public static void main(String[] args) {

}
}

k的值由随机数对象决定,所以不是所有的final修饰的字段都是编译期常量,只是k的值在被初始化后无法被更改。

2、static final

一个既是static又是final 的字段只占据一段不能改变的存储空间,它必须在定义的时候进行赋值,否则编译器将不予通过

1
2
3
4
5
6
7
8
9
10
11
12
import java.util.Random;
public class Test {
static Random r = new Random();
final int k = r.nextInt(10);
static final int k2 = r.nextInt(10);
public static void main(String[] args) {
Test t1 = new Test();
System.out.println("k="+t1.k+" k2="+t1.k2);
Test t2 = new Test();
System.out.println("k="+t2.k+" k2="+t2.k2);
}
}

上面代码某次输出结果:

1
2
k=2 k2=7
k=8 k2=7

我们可以发现对于不同的对象k的值是不同的,但是k2的值却是相同的,这是为什么呢?

  • 因为static关键字所修饰的字段并不属于一个对象,而是属于这个类的。
  • 也可简单的理解为static final所修饰的字段仅占据内存的一个一份空间,一旦被初始化之后便不会被更改
3、blank final

Java允许生成空白final,也就是说被声明为final但又没有给出定值的字段,但是必须在该字段被使用之前被赋值,这给予我们两种选择:

  • 在定义处进行赋值(这不叫空白final)
  • 在构造器中进行赋值,保证了该值在被使用前赋值。

这增强了final的灵活性。

看下面代码:

1
2
3
4
5
6
7
8
9
10
public class Test {
final int i1 = 1;
final int i2;//空白final
public Test() {
i2 = 1;
}
public Test(int x) {
this.i2 = x;
}
}

可以看到i2的赋值更为灵活。

但是请注意,如果字段由static和final修饰,仅能在定义处赋值,因为该字段不属于对象,属于这个类。

3、final域重排序规则

上面final的使用,应该属于Java基础层面的,当理解这些后我们就真的算是掌握了final吗? 有考虑过final在多线程并发的情况吗?

在java内存模型中我们知道java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。

那么,在多线程情况下,final会进行怎样的重排序? 会导致线程安全的问题吗?

下面,就来看看final的重排序。

1、final域为基本类型

先看一段示例性的代码:(假设线程A在执行writer()方法,线程B执行reader()方法。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;

public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}

public static void writer() {
finalDemo = new FinalDemo();
}

public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
1、写final域重排序规则

写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:

  • JMM禁止编译器把final域的写重排序到构造函数之外;
  • 编译器会在final域写之后,构造函数return之前,插入一个storestore屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。

我们再来分析writer方法,虽然只有一行代码,但实际上做了两件事情:

  • 构造了一个FinalDemo对象;
  • 把这个对象赋值给成员变量finalDemo。

我们来画下存在的一种可能执行时序图,如下:

img

由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。

因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo

2、读final域重排序规则

读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。

read()方法主要包含了三个操作:

  • 初次读引用变量finalDemo;
  • 初次读引用变量finalDemo的普通域a;
  • 初次读引用变量finalDemo的final域b;

假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:

img

读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。

读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用

2、final域为引用类型

1、对final域修饰的对象的成员域写操作

针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的

注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;

public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}

public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}

public void writerTwo() {
arrays[0] = 2; //4
}

public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}

针对上面的实例程序,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论。

img

由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序

2、对final域修饰的对象的成员域读操作

JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。

3、关于final重排序的总结

按照final修饰的数据类型分类:

  • 基本数据类型:
    • final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
    • final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
  • 引用数据类型:
    • 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序

4、final再深入理解

1、final的实现原理

上面我们提到过,写final域会要求编译器在final域写之后,构造函数返回前插入一个StoreStore屏障。读final域的重排序规则会要求编译器在读final域的操作前插入一个LoadLoad屏障。

很有意思的是,如果以X86处理为例,X86不会对写-写重排序,所以StoreStore屏障可以省略。由于不会对有间接依赖性的操作重排序,所以在X86处理器中,读final域需要的LoadLoad屏障也会被省略掉。也就是说,以X86为例的话,对final域的读/写的内存屏障都会被省略!具体是否插入还是得看是什么处理器

1、设置 final 变量的原理:
1
2
3
public class TestFinal {
final int a = 20;
}

字节码:

1
2
3
4
5
6
7
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0 20
5: bipush #2 // Field a:I
7: putfield
<-- 写屏障
10: return

发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况。

2、获取 final 变量的原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class TestFinal {
static int A = 10;
final static int B = Short.MAX_VALUE+1;

final int a = 20;
final int b = Integer.MAX_VALUE;

final void test1() {
final int c = 30;
new Thread(()->{
System.out.println(c);
}).start();

final int d = 30;
class Task implements Runnable {

@Override
public void run() {
System.out.println(d);
}
}
new Thread(new Task()).start();
}

}

class UseFinal1 {
public void test() {
System.out.println(TestFinal.A);
System.out.println(TestFinal.B);
System.out.println(new TestFinal().a);
System.out.println(new TestFinal().b);
new TestFinal().test1();
}
}

class UseFinal2 {
public void test() {
System.out.println(TestFinal.A);
}
}

结果:

1

分析:

  • 如果final static int A = 10;加入final的话:那么在字节码的层面可以看到:BIPUSH 10,即在读取A的时候,它是从栈中直接复制了一个10给到了A,走的不是共享这条路(没有从其他类中读取数据)
    • image-20210810231107766
  • 如果static int A = 10;没有加final的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.A : I,即在读取A的时候,它是从TestFinal类中获取的到的10,走的是共享这条路(从其他类中读取数据),那么A就是==共享内存==,性能比==栈内存==要低。
    • image-20210810231345476
  • 对于final static int B = Short.MAX_VALUE+1;来说,B是Short的最大值在加上1(超过了极限)。加入final的话:那么在字节码的层面可以看到:LDC 32768(Short的最大值是32767),即读取的是常量池当中的内容,同理也没有走共享内存这条路(没有从其他类中读取数据)
    • image-20210810232209213
  • 如果B没有加final的话:那么在字节码的层面可以看到:GETSTATIC cn/itcast/n5/TestFinal.B : I,即在读取B的时候,它是从TestFinal类中获取的,走的是共享这条路(从其他类中读取数据)
    • image-20210810232531747
  • 下面的成员变量ab也是同样的道理:加入final修饰的,在引用到a/b的时候,会复制一份到调用方的常量池当中,直接从栈内存获取就行(==栈内存==),效率高。没有加final修饰的,在引用到a/b的时候,会直接到类中获取(==共享内存==),效率比较低。
  • 总结:一个final修饰的基本变量可以完全等价于一个常量,整个jvm实例生命周期内都不会变化了,这个值在编译时就已经写死成直接引用了

2、为什么final引用不能从构造函数中”溢出”

这里还有一个比较有意思的问题:上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。

但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“溢出”。以下面的例子来说:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;

public FinalReferenceEscapeDemo() {
a = 1; //1
referenceDemo = this; //2
}

public void writer() {
new FinalReferenceEscapeDemo();
}

public void reader() {
if (referenceDemo != null) { //3
int temp = referenceDemo.a; //4
}
}
}

可能的执行时序如图所示:

img

假设一个线程A执行writer方法另一个线程执行reader方法。因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this”逸出,该代码依然存在线程安全的问题。

3、使用final的限制条件和局限性

  • 当声明一个 final 成员时,必须在构造函数退出前设置它的值。

    1
    2
    3
    4
    5
    6
    public class MyClass {
    private final int myField = 1;
    public MyClass() {
    ...
    }
    }
    • 或者
    1
    2
    3
    4
    5
    6
    7
    8
    public class MyClass {
    private final int myField;
    public MyClass() {
    ...
    myField = 1;
    ...
    }
    }
  • 将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。

    • 下面的方法仍然可以修改该 list。

      1
      2
      private final List myList = new ArrayList();
      myList.add("Hello");
    • 声明为 final 可以保证如下操作不合法

      1
      2
      myList = new ArrayList();
      myList = someOtherList;
  • 如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。

    • “ 其他方式 “ 可以包括声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。

4、再思考一个有趣的现象

1
2
3
byte b1=1;
byte b2=3;
byte b3=b1+b2;//当程序执行到这一行的时候会出错,因为b1、b2可以自动转换成int类型的变量,运算时java虚拟机对它进行了转换,结果导致把一个int赋值给byte-----出错

如果对b1 b2加上final就不会出错:

1
2
3
final byte b1=1;
final byte b2=3;
byte b3=b1+b2;//不会出错,相信你看了上面的解释就知道原因了。

7、JUC(java.util.concurrent)

0、JUC - 类汇总和学习总览

1、BAT大厂的面试问题

  • JUC框架包含几个部分?
  • 每个部分有哪些核心的类?
  • 最最核心的类有哪些?

2、Overview

JUC相关的五大类与框架:

image

主要包含: (注意: 上图是网上找的图,无法表述一些继承关系,同时少了部分类;但是主体上可以看出其分类关系也够了)

  • Lock框架和Tools类(把图中这两个放到一起理解)
  • Collections: 并发集合
  • Atomic: 原子类
  • Executors: 线程池

3、相关类与框架

1、Lock框架和Tools类

类结构总览:

image

  • 接口
    • Condition
      • Condition为接口类型,它将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。
      • 其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。可以通过await(),signal()来休眠/唤醒线程。
    • Lock
      • Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象
    • ReadWriteLock
      • ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
  • 抽象类
    • AbstractOwnableSynchonizer
      • AbstractOwnableSynchonizer为抽象类,可以由线程以独占方式拥有的同步器。
      • 此类为创建锁和相关同步器(伴随着所有权的概念)提供了基础。
      • AbstractOwnableSynchronizer 类本身不管理或使用此信息。但是,子类和工具可以使用适当维护的值帮助控制和监视访问以及提供诊断。
    • (Long)AbstractQueuedLongSynchonizer
      • AbstractQueuedLongSynchronizer为抽象类,以 long 形式维护同步状态的一个 AbstractQueuedSynchronizer 版本。
      • 此类具有的结构、属性和方法与 AbstractQueuedSynchronizer 完全相同,但所有与状态相关的参数和结果都定义为 long 而不是 int。
      • 当创建需要 64 位状态的多级别锁和屏障等同步器时,此类很有用。
    • 核心抽象类(int):AbstractQueuedSynchonizer
      • AbstractQueuedSynchonizer为抽象类,其为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。
      • 此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。
  • 锁常用类
    • LockSupport
      • LockSupport为常用类,用来创建锁和其他同步类的基本线程阻塞原语。
      • LockSupport的功能和”Thread中的 Thread.suspend()和Thread.resume()有点类似”,LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。
      • 但是park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。
    • ReentrantLock
      • ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
    • ReentrantReadWriteLock
      • ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。
    • StampedLock
      • 它是java8在java.util.concurrent.locks新增的一个API。
      • StampedLock控制锁有三种模式(写,读,乐观读),一个StampedLock状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据stamp,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。
  • 工具常用类
    • CountDownLatch
      • CountDownLatch为常用类,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。
    • CyclicBarrier
      • CyclicBarrier为常用类,其是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。
      • 在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。
    • Phaser
      • Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
    • Semaphore
      • Semaphore为常用类,其是一个计数信号量,从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。
      • 但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。
    • Exchanger
      • Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换。
      • 它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
2、Collections:并发集合

类结构关系:

image

  • Queue
    • ArrayBlockingQueue
      • 一个由数组支持的有界阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。
      • 队列的头部是在队列中存在时间最长的元素。队列的尾部是在队列中存在时间最短的元素。
      • 新元素插入到队列的尾部,队列获取操作则是从队列头部开始获得元素。
    • LinkedBlockingQueue
      • 一个基于已链接节点的、范围任意的 blocking queue。此队列按 FIFO(先进先出)排序元素。
      • 队列的头部是在队列中时间最长的元素。队列的尾部是在队列中时间最短的元素。
      • 新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。
      • 链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。
    • LinkedBlockingDeque
      • 一个基于已链接节点的、任选范围的阻塞双端队列。
    • ConcurrentLinkedQueue
      • 一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。
      • 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素。
      • 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
      • 当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。
    • ConcurrentLinkedDeque
      • 是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。
    • DelayQueue
      • 延时无界阻塞队列,使用Lock机制实现并发访问。
      • 队列里只允许放可以“延期”的元素,队列中的head是最先“到期”的元素。
      • 如果队里中没有元素到“到期”,那么就算队列中有元素也不能获取到。
    • PriorityBlockingQueue
      • 无界优先级阻塞队列,使用Lock机制实现并发访问。
      • priorityQueue的线程安全版,不允许存放null值,依赖于comparable的排序,不允许存放不可比较的对象类型。
    • SynchronousQueue
      • 没有容量的同步队列,通过CAS实现并发访问,支持FIFO和FILO。
    • LinkedTransferQueue
      • JDK 7新增,单向链表实现的无界阻塞队列,通过CAS实现并发访问,队列元素使用 FIFO(先进先出)方式。
      • LinkedTransferQueue可以说是ConcurrentLinkedQueue、SynchronousQueue(公平模式)和LinkedBlockingQueue的超集,它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。
  • List
    • CopyOnWriteArrayList
      • ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。
      • 这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。
  • Set
    • CopyOnWriteArraySet
      • 对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。
      • 在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。
    • ConcurrentSkipListSet
      • 一个基于ConcurrentSkipListMap 的可缩放并发 NavigableSet 实现。set 的元素可以根据它们的自然顺序进行排序,也可以根据创建 set 时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
  • Map
    • ConcurrentHashMap
      • 是线程安全HashMap的。ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。
    • ConcurrentSkipListMap
      • 线程安全的有序的哈希表(相当于线程安全的TreeMap);映射可以根据键的自然顺序进行排序,也可以根据创建映射时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
3、Atomic: 原子类

其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。

  • 基础类型
    • AtomicBoolean
      • 针对bool的原子类。
    • AtomicInteger
      • 针对interger的原子类。
    • AtomicLong
      • 针对long的原子类。
  • 数组
    • AtomicIntegerArray
    • AtomicLongArray
    • BooleanArray
  • 引用
    • AtomicReference
    • AtomicMarkedReference
    • AtomicStampedReference
  • FieldUpdater
    • AtomicLongFieldUpdater
    • AtomicIntegerFieldUpdater
    • AtomicReferenceFieldUpdater
4、Executors:线程池

类结构关系:

img

  • 接口:Executor
    • Executor接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。
  • ExecutorService
    • ExecutorService继承自Executor接口,ExecutorService提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。
  • ScheduledExecutorService
    • ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。
  • AbstractExecutorService
    • AbstractExecutorService继承自ExecutorService接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。
  • FutureTask
    • FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。
    • 如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。
    • FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。
    • 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。
    • FutureTask 的线程安全由CAS来保证。
  • 核心
    • ThreadPoolExecutor
      • ThreadPoolExecutor实现了AbstractExecutorService接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。
      • 线程池可以解决两个不同问题:由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。
    • ScheduledThreadExecutor
      • ScheduledThreadPoolExecutor实现ScheduledExecutorService接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。
    • Fork/Join框架
      • ForkJoinPool 是JDK 7加入的一个线程池类。
      • Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。
      • 目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。
  • 工具类:Executors
    • Executors是一个工具类,用其可以创建ExecutorService、ScheduledExecutorService、ThreadFactory、Callable等对象。
    • 它的使用融入到了ThreadPoolExecutor、ScheduledThreadExecutor和ForkJoinPool中。

1、JUC概述

1、什么是JUC

在 Java 中,线程部分是一个重点,本篇文章说的 JUC 也是关于线程的。JUC就是 java.util .concurrent 工具包的简称。这是一个处理线程的工具包,JDK 1.5 开始出现的。

image-20210721220607339

2、多线程编程步骤

  1. 第一:创建资源类,创建属性和操作方法
  2. 第二:在资源类的操作方法中
    1. 判断(使用while,不使用if,或者会出现虚假唤醒问题)
    2. 干活
    3. 通知
  3. 第三:创建多线程调用资源类的方法
  4. 第四:防止出现虚假唤醒问题
虚假唤醒问题
1、什么是虚假唤醒问题?

当一个条件满足时,很多线程都被唤醒了,但是只有其中部分是有用的唤醒,其它的唤醒都是无用的唤醒;这些无用的唤醒会导致出现一些问题。

2、为什么会出现虚假唤醒问题?

在多线程编程步骤的第二步中的判断中,如果使用的是if语句的话,就会出现虚假唤醒问题。原因:

  • if语句块只会判断一次
  • wait()方法的特性:在哪里等待,就在哪里开始

在官方文档中就明确规定要**==使用while语句块==,不要使用if语句块**:(因为while语句块可以不断的进行判断)

在这里插入图片描述

3、示例(参考下面进程间通信的代码——将其中的while修改为if,并运行发现)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
A=>1
B=>0
A=>1
B=>0
A=>1
B=>0
C=>1
A=>2
C=>3
B=>2
B=>1
B=>0
C=>1
A=>2
C=>3
B=>2
D=>1
D=>0
D=>-1
C=>0
C=>1
A=>2
C=>3
D=>2
D=>1
D=>0
C=>1
A=>2
C=>3
D=>2
D=>1
D=>0
C=>1
D=>0
C=>1
D=>0
4、分析(假设一开始为0)
  • 调用A –> 1
  • 调用C –> 1 C wait
  • 如果再调用A,那么A也会wait,A wait –> 1
  • 再调用B减1后, –> 0
  • 唤醒了A和C,执行A(C没抢到), –> 1
  • A执行完后,C抢到了CPU,此时C没有再进行判断,直接执行+1操作, –> 2
5、使用wait/notify的正确姿势——防止虚假唤醒问题
1
2
3
4
5
6
7
8
9
10
11
synchronized(lock) {
while(条件不成立) {
lock.wait();
}
// 干活
}

//另一个线程
synchronized(lock) {
lock.notifyAll();
}

2、JUC原子类:CAS,Unsafe和原子类详解

JUC中多数类是通过volatile和CAS来实现的,CAS本质上提供的是一种无锁方案,而Synchronized和Lock是互斥锁方案;java原子类本质上使用的是CAS,而CAS底层是通过Unsafe类实现的。

1、BAT大厂的面试问题

  • 线程安全的实现方法有哪些?
  • 什么是CAS?
  • CAS使用示例,结合AtomicInteger给出示例?
  • CAS会有哪些问题?
  • 针对这这些问题,Java提供了哪几个解决的?
  • AtomicInteger底层实现?
    • CAS + volatile
  • 请阐述你对Unsafe类的理解?
  • 说说你对Java原子类的理解?包含13个,4组分类,说说作用和使用场景。
  • AtomicStampedReference是什么?
  • AtomicStampedReference是怎么解决ABA的?
    • 内部使用Pair来存储元素值及其版本号
  • java中还有哪些类可以解决ABA的问题?
    • AtomicMarkableReference

2、CAS

前面我们说到,线程安全的实现方法包含:

  • 互斥同步:synchronizedReentrantLock
  • 非阻塞同步:CASAtomicXXXX
  • 无同步方案:栈封闭Thread Local可重入代码
1、什么是CAS

CAS的全称为Compare-And-Swap,直译就是对比交换。是一条CPU的原子指令,其作用是让CPU先进行比较两个值是否相等,然后原子地更新某个位置的值,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。

CAS操作是原子性的,所以多线程并发使用CAS更新数据时,可以不使用锁。JDK中大量使用了CAS来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。

相信sql大家都熟悉,类似sql中的条件更新一样:update set id=3 from table where id=2。因为单条sql执行具有原子性,如果有多个线程同时执行此sql语句,只有一条能更新成功。但如果有多条sql的话,要保证操作的原子性,就要使用事务了。

2、CAS使用实例

如果不使用CAS,在高并发下,多线程同时修改一个变量的值我们需要synchronized加锁(可能有人说可以用Lock加锁,Lock底层的AQS也是基于CAS进行获取锁的)。

1
2
3
4
5
6
public class Test {
private int i = 0;
public synchronized int add(){
return i++;
}
}

java中为我们提供了AtomicInteger 原子类(底层基于CAS进行更新数据的),不需要加锁就在多线程并发场景下实现数据的一致性。

1
2
3
4
5
6
public class Test {
private AtomicInteger i = new AtomicInteger(0);
public int add(){
return i.addAndGet(1);
}
}
3、为什么无锁效率高
  • 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
  • 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大
  • 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
4、CAS 的特点

结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  • CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思:
    • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5、CAS问题

CAS 方式为乐观锁,synchronized 为悲观锁。因此使用 CAS 解决并发问题通常情况下性能更优。

但使用 CAS 方式也会有几个问题:

  • ABA问题
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作
1、ABA问题

因为CAS需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时则会发现它的值没有发生变化,但是实际上却变化了。

ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A->B->A就会变成1A->2B->3A。

从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值

2、循环时间长开销大

自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

pause指令有两个作用:

  1. 第一,它可以延迟流水线执行命令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
  2. 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3、只能保证一个共享变量的原子操作

当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。

还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i = 2,j = a,合并一下ij = 2a,然后用CAS来操作ij。

从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作

3、Unsafe类详解

Java原子类是通过UnSafe类实现的,UnSafe类在J.U.C中CAS操作有很广泛的应用。

Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。

由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。

这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。

Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 通过反射获取一个unsafe对象
public class UnsafeAccessor {
static Unsafe unsafe;
static {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
throw new Error(e);
}
}

static Unsafe getUnsafe() {
return unsafe;
}
}

先来看下这张图,对UnSafe类总体功能:

img

如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类。

1、Unsafe与CAS

反编译出来的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public final int getAndAddInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
return i;
}

public final long getAndAddLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, l + paramLong2));
return l;
}

public final int getAndSetInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, paramInt));
return i;
}

public final long getAndSetLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, paramLong2));
return l;
}

public final Object getAndSetObject(Object paramObject1, long paramLong, Object paramObject2)
{
Object localObject;
do
localObject = getObjectVolatile(paramObject1, paramLong);
while (!compareAndSwapObject(paramObject1, paramLong, localObject, paramObject2));
return localObject;
}

从源码中发现,**内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)**。

又从Unsafe类中发现,原子操作其实只支持下面3种CAS方法:(都是native方法)

  1. compareAndSwapObject
  2. compareAndSwapInt
  3. compareAndSwapLong
1
2
3
4
5
public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);

public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);

public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);

三个方法都是有四个参数,这四个参数的含义都是一样的,分别是:(以compareAndSwapInt为例)

  • Object paramObject:操作的对象
  • long paramLong:操作对象的操作域的偏移地址
  • int paramInt1:原值
  • int paramInt2:修改值
2、使用unsafe

我们使用反射得到的unsafe来完成一些操作

1、使用unsafe去修改一个对象的字段(域)的取值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import lombok.Data;
import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class TestUnsafe {

public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);

// System.out.println(unsafe);

// 1. 获取域的偏移地址
long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));

Teacher t = new Teacher();
// 2. 执行 cas 操作
unsafe.compareAndSwapInt(t, idOffset, 0, 1);
unsafe.compareAndSwapObject(t, nameOffset, null, "张三");

// 3. 验证
System.out.println(t);
}
}
@Data
class Teacher {
volatile int id;
volatile String name;
}

输出:

1
Theater{id=1,name="张三"}
2、使用unsafe模拟实现原值整数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import cn.itcast.n4.UnsafeAccessor;
import lombok.extern.slf4j.Slf4j;
import sun.misc.Unsafe;

@Slf4j(topic = "c.Test42")
public class Test42 {
public static void main(String[] args) {
Account.demo(new MyAtomicInteger(10000));
}
}

class MyAtomicInteger implements Account {
private volatile int value;
private static final long valueOffset;
private static final Unsafe UNSAFE;
static {
UNSAFE = UnsafeAccessor.getUnsafe();
try {
valueOffset = UNSAFE.objectFieldOffset(MyAtomicInteger.class.getDeclaredField("value"));
} catch (NoSuchFieldException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}

public int getValue() {
return value;
}

public void decrement(int amount) {
while(true) {
int prev = this.value;
int next = prev - amount;
if (UNSAFE.compareAndSwapInt(this, valueOffset, prev, next)) {
break;
}
}
}

public MyAtomicInteger(int value) {
this.value = value;
}

@Override
public Integer getBalance() {
return getValue();
}

@Override
public void withdraw(Integer amount) {
decrement(amount);
}
}
2、Unsafe底层

Unsafe的compareAndSwap方法来实现CAS操作,它是一个本地方法,实现位于unsafe.cpp中。

1
2
3
4
5
6
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

可以看到它通过 Atomic::cmpxchg 来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。

如果是Linux的x86,Atomic::cmpxchg方法的实现如下:

1
2
3
4
5
6
7
8
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP();
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}

而windows的x86的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::isMP(); //判断是否是多处理器
_asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:

如果是多处理器,为cmpxchg指令添加lock前缀。反之,就省略lock前缀(单处理器会不需要lock前缀提供的内存屏障效果)。这里的lock前缀就是使用了处理器的总线锁(最新的处理器都使用缓存锁代替总线锁来提高性能)。

cmpxchg(void* ptr, int old, int new),如果ptr和old的值一样,则把new写到ptr内存,否则返回ptr的值,整个操作是原子的。在Intel平台下,会用lock cmpxchg来实现,使用lock触发缓存锁,这样另一个线程想访问ptr的内存,就会被block住。

3、Unsafe其他功能

Unsafe 提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。

举两个例子,比方说:这个方法可以用来获取给定的 paramField 的内存地址偏移量,这个值对于给定的 field 是唯一的且是固定不变的。

1
public native long staticFieldOffset(Field paramField);

再比如说:前一个方法是用来获取数组第一个元素的偏移地址,后一个方法是用来获取数组的转换因子即数组中元素的增量地址的。

1
2
public native int arrayBaseOffset(Class paramClass);
public native int arrayIndexScale(Class paramClass);

最后看三个方法:分别用来分配内存,扩充内存和释放内存的。

1
2
3
public native long allocateMemory(long paramLong);
public native long reallocateMemory(long paramLong1, long paramLong2);
public native void freeMemory(long paramLong);

更多相关功能,推荐你看下这篇文章:来自美团技术团队:Java魔法类:Unsafe应用解析

4、AutomicIntrger

1、使用举例

以 AtomicInteger 为例,常用 API:

1
2
3
4
5
6
public final int get():获取当前的值
public final int getAndSet(int newValue):获取当前的值,并设置新的值
public final int getAndIncrement():获取当前的值,并自增
public final int getAndDecrement():获取当前的值,并自减
public final int getAndAdd(int delta):获取当前的值,并加上预期的值
void lazySet(int newValue): 最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

相比 Integer 的优势,多线程中让变量自增:

1
2
3
4
5
6
7
8
private volatile int count = 0;
// 若要线程安全执行执行 count++,需要加锁
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}

使用 AtomicInteger 后:

1
2
3
4
5
6
7
8
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
// 使用 AtomicInteger 后,不需要加锁,也可以实现线程安全
public int getCount() {
return count.get();
}
2、源码解析
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
//用于获取value字段相对当前对象的“起始地址”的偏移量
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

//返回当前值
public final int get() {
return value;
}

//递增加detla
public final int getAndAdd(int delta) {
//三个参数,1、当前的实例 2、value实例变量的偏移量 3、当前value要加上的数(value+delta)。
return unsafe.getAndAddInt(this, valueOffset, delta);
}

//递增加1
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// ...
}

我们可以看到 AtomicInteger 底层用的是volatile的变量和CAS来进行更改数据的。

  • volatile保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值
  • CAS 保证数据更新的原子性。

5、延伸到所有原子类:共13个

JDK中提供了13个原子操作类。

1、原子更新基本类型

使用原子的方式更新基本类型,Atomic包提供了以下3个类。

  • AtomicBoolean:原子更新布尔类型。
  • AtomicInteger:原子更新整型。
  • AtomicLong:原子更新长整型。

以上3个类提供的方法几乎一模一样,可以参考上面AtomicInteger中的相关方法。

其它方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
AtomicInteger i = new AtomicInteger(0);
// 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++
System.out.println(i.getAndIncrement());
// 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i
System.out.println(i.incrementAndGet());
// 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i
System.out.println(i.decrementAndGet());
// 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
System.out.println(i.getAndDecrement());
// 获取并加值(i = 0, 结果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
// 加值并获取(i = 5, 结果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
// 获取并更新(i = 0, p 为 i 的当前值, 结果 i = -2, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.updateAndGet(p -> p + 2));
// 获取并计算(i = 0, p 为 i 的当前值, x 为参数1, 结果 i = 10, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
// getAndUpdate 如果在 lambda 中引用了外部的局部变量,要保证该局部变量是 final 的
// getAndAccumulate 可以通过 参数1 来引用外部的局部变量,但因为其不在 lambda 中因此不必是 final
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 计算并获取(i = 10, p 为 i 的当前值, x 为参数1, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));
2、原子更新数组

通过原子的方式更新数组里的某个元素,Atomic包提供了以下的4个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。

  • AtomicLongArray:原子更新长整型数组里的元素。

  • AtomicReferenceArray:原子更新引用类型数组里的元素。

    这三个类的最常用的方法是如下两个方法:

  • get(int index):获取索引为index的元素值。

  • compareAndSet(int i, E expect, E update):如果当前值等于预期值,则以原子方式将数组位置i的元素设置为update值。

举个AtomicIntegerArray例子:

1
2
3
4
5
6
7
8
9
10
import java.util.concurrent.atomic.AtomicIntegerArray;

public class Demo5 {
public static void main(String[] args) throws InterruptedException {
AtomicIntegerArray array = new AtomicIntegerArray(new int[] { 0, 0 });
System.out.println(array);
System.out.println(array.getAndAdd(1, 2));
System.out.println(array);
}
}
1
2
3
[0, 0]
0
[0, 2]
3、原子更新引用类型

Atomic包提供了以下三个类:

  • AtomicReference:原子更新引用类型。
  • AtomicStampedReference:原子更新引用类型,内部使用Pair来存储元素值及其版本号。(可以解决CAS的ABA问题)
  • AtomicMarkableReferce:原子更新带有标记位的引用类型。
    • 有时候,并不关心引用变量更改了几次(ABA问题),只是单纯的关心是否更改过,所以就有了AtomicMarkableReference

这三个类提供的方法都差不多:

  1. 首先构造一个引用对象;
  2. 然后把引用对象set进Atomic类;
  3. 然后调用compareAndSet等一些方法去进行原子操作。

原理都是基于Unsafe实现,但AtomicReferenceFieldUpdater略有不同,更新的字段必须用volatile修饰

举个AtomicReference例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.atomic.AtomicReference;

public class AtomicReferenceTest {

public static void main(String[] args){

// 创建两个Person对象,它们的id分别是101和102。
Person p1 = new Person(101);
Person p2 = new Person(102);
// 新建AtomicReference对象,初始化它的值为p1对象
AtomicReference ar = new AtomicReference(p1);
// 通过CAS设置ar。如果ar的值为p1的话,则将其设置为p2。
ar.compareAndSet(p1, p2);

Person p3 = (Person)ar.get();
System.out.println("p3 is "+p3);
System.out.println("p3.equals(p2)="+p3.equals(p2));
}
}

class Person {
volatile long id;
public Person(long id) {
this.id = id;
}
public String toString() {
return "id:"+id;
}
}
1
2
p3 is id:102
p3.equals(p2)=false

结果说明:

  • 新建AtomicReference对象ar时,将它初始化为p1。
  • 紧接着,通过CAS函数对它进行设置。如果ar的值为p1的话,则将其设置为p2。
  • 最后,获取ar对应的对象,并打印结果。p3.equals(p2)的结果为false。
    • 这是因为Person并没有覆盖equals()方法,而是采用继承自Object.java的equals()方法;而Object.java中的equals()实际上是调用”==”去比较两个对象,即比较两个对象的地址是否相等。

举个AtomicMarkableReference例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.atomic.AtomicMarkableReference;

import static cn.itcast.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.Test38")
public class Test38 {
public static void main(String[] args) throws InterruptedException {
GarbageBag bag = new GarbageBag("装满了垃圾");
// 参数2 mark 可以看作一个标记,表示垃圾袋满了
AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true);

log.debug("start...");
GarbageBag prev = ref.getReference();
log.debug(prev.toString());

new Thread(() -> {
log.debug("start...");
bag.setDesc("空垃圾袋");
ref.compareAndSet(bag, bag, true, false);
log.debug(bag.toString());
},"保洁阿姨").start();

sleep(1);
log.debug("想换一只新垃圾袋?");
boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false);
log.debug("换了么?" + success);
log.debug(ref.getReference().toString());
}
}

class GarbageBag {
String desc;

public GarbageBag(String desc) {
this.desc = desc;
}

public void setDesc(String desc) {
this.desc = desc;
}

@Override
public String toString() {
return super.toString() + " " + desc;
}
}
4、原子更新字段类(原子更新器)

Atomic包提供了四个类进行原子字段更新:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。
  • AtomicStampedFieldUpdater:原子更新带有版本号的引用类型。
  • AtomicReferenceFieldUpdater:上面已经说过此处不在赘述。

这四个类的使用方式都差不多,是基于反射的原子更新字段的值。要想原子地更新字段类需要两步:

  1. 第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性
  2. 第二步,更新类的字段必须使用public volatile修饰。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class TestAtomicIntegerFieldUpdater {

public static void main(String[] args){
TestAtomicIntegerFieldUpdater tIA = new TestAtomicIntegerFieldUpdater();
tIA.doIt();
}

public AtomicIntegerFieldUpdater<DataDemo> updater(String name){
return AtomicIntegerFieldUpdater.newUpdater(DataDemo.class,name);
}

public void doIt(){
DataDemo data = new DataDemo();
System.out.println("publicVar = "+updater("publicVar").getAndAdd(data, 2));
/*
* 由于在DataDemo类中属性value2/value3,在TestAtomicIntegerFieldUpdater中不能访问
*/
//System.out.println("protectedVar = "+updater("protectedVar").getAndAdd(data,2));
//System.out.println("privateVar = "+updater("privateVar").getAndAdd(data,2));

//报java.lang.IllegalArgumentException
//System.out.println("staticVar = "+updater("staticVar").getAndIncrement(data));

/*
* 下面报异常:must be integer
*/
//System.out.println("integerVar = "+updater("integerVar").getAndIncrement(data));
//System.out.println("longVar = "+updater("longVar").getAndIncrement(data));
}

}

class DataDemo{
public volatile int publicVar=3;
protected volatile int protectedVar=4;
private volatile int privateVar=5;

public volatile static int staticVar = 10;
//public final int finalVar = 11;

public volatile Integer integerVar = 19;
public volatile Long longVar = 18L;

}

再说下对于AtomicIntegerFieldUpdater 的使用稍微有一些限制和约束,约束如下:

  • 字段必须是volatile类型的,在线程之间共享变量时保证立即可见
    • eg:volatile int value = 3
  • 字段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。但是对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
  • 只能是实例变量,不能是类变量,也就是说不能加static关键字
  • 只能是可修改变量,不能是final变量,因为final的语义就是不可修改实际上final的语义和volatile是有冲突的,这两个关键字不能同时存在。
  • 对于AtomicIntegerFieldUpdater和AtomicLongFieldUpdater只能修改int/long类型的字段,不能修改其包装类型(Integer/Long)。如果要修改包装类型就需要使用AtomicReferenceFieldUpdater
5、原子累加器

原子类型累加器JDK1.8引进的并发新技术,它可以看做AtomicLongAtomicDouble的部分加强类型。

原子类型累加器有如下四种:

  • DoubleAccumulator
  • DoubleAdder
  • LongAccumulator
  • LongAdder

由于上面四种累加器的原理类似,下面以LongAdder为列来介绍累加器的使用。

已经有AtomicLong的getAndIncrement()方法进行累加效果,为什么还要有LongAdder累加器?

  • 我们知道,AtomicLong是利用了底层的CAS操作来提供并发性的,比如addAndGet方法:

  • public final long addAndGet(long delta) {
        return unsafe.getAndAddLong(this, valueOffset, delta) + delta;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39

    - 上述方法调用了**Unsafe**类的**getAndAddLong**方法,该方法是个**native**方法,它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。

    - 在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时**AtomicLong**的自旋会成为瓶颈。

    - 这就是**LongAdder**引入的初衷——解决高并发环境下**AtomicLong**的自旋瓶颈问题。

    - 而**LongAdder**的基本思路就是**分散热点**,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。

    - 如果要获取真正的long值,只要将各个槽中的变量值累加返回。

    - ConcurrentHashMap中的“分段锁”其实就是类似的思路。

    ###### 1、累加器性能比较——比较 AtomicLong 与 LongAdder

    ```java
    private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {
    T adder = adderSupplier.get();
    long start = System.nanoTime();
    List<Thread> ts = new ArrayList<>(); // 4 个线程,每人累加 50 万
    for (int i = 0; i < 40; i++) {
    ts.add(new Thread(() -> {
    for (int j = 0; j < 500000; j++) {
    action.accept(adder);
    }
    }));
    }
    ts.forEach(t -> t.start());
    ts.forEach(t -> {
    try {
    join();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    });

    long end = System.nanoTime();
    System.out.println(adder + " cost:" + (end - start)/1000_000);
    }

比较 AtomicLong 与 LongAdder:

1
2
3
4
5
6
7
for (int i = 0; i < 5; i++) {
demo(() -> new LongAdder(), adder -> adder.increment());
}

for (int i = 0; i < 5; i++) {
demo(() -> new AtomicLong(), adder -> adder.getAndIncrement());
}

输出:

1
2
3
4
5
6
7
8
9
10
11
1000000 cost:43 
1000000 cost:9
1000000 cost:7
1000000 cost:7
1000000 cost:7

1000000 cost:31
1000000 cost:27
1000000 cost:28
1000000 cost:24
1000000 cost:22

性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。

2、LongAdder源码

LongAdder 是并发大师 @author Doug Lea (大哥李)的作品,设计的非常精巧

类的继承关系:

1
2
3
public class LongAdder extends Striped64 implements Serializable {...}
// Striped64这个类实现一些核心操作,处理64位数据。
abstract class Striped64 extends Number {...}

LongAdder 类有几个关键域:(这几个的关键域定义在Striped64抽象类中)

1
2
3
4
5
6
// 累加单元数组, 懒惰初始化
transient volatile Cell[] cells;
// 基础值, 如果没有竞争, 则用 cas 累加这个域
transient volatile long base;
// 在 cells 创建或扩容时, 置为 1, 表示加锁(cas锁)
transient volatile int cellsBusy;

其中 Cell 即为累加单元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 防止缓存行伪共享
@sun.misc.Contended
static final class Cell {
volatile long value;
Cell(long x) { value = x; }
// 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值
final boolean cas(long cmp, long val) {
return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
}

// Unsafe mechanics
private static final sun.misc.Unsafe UNSAFE;
private static final long valueOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> ak = Cell.class;
valueOffset = UNSAFE.objectFieldOffset
(ak.getDeclaredField("value"));
} catch (Exception e) {
throw new Error(e);
}
}
}
3、缓存(伪共享问题)

缓存与内存的速度比较:

image-20210807034923018

因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。

而缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)

缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中

CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。

可以通过缓存一致性协议(MESI)保证:

缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 “ 嗅探(snooping)" 协议

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效

伪共享:

image-20210807035727427

因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行可以存下 2 个的 Cell 对象。这样问题来了:

  • Core-0 要修改 Cell[0]
  • Core-1 要修改 Cell[1]

无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中 Cell[0]=6000, Cell[1]=8000 要累加Cell[0]=6001, Cell[1]=8000 Q,这时会让 Core-1 的缓存行失效。这种问题被叫做伪共享问题。

@sun.misc.Contended 用来解决这个伪共享问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效

image-20210807040114815

4、核心方法——increment()
1
2
3
public void increment() {
add(1L);
}

说明:increment()方法调用了add()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void add(long x) {
// as 为累加单元数组
// b 为基础值
// x 为累加值
Cell[] as; long b, v; int m; Cell a;
// 进入 if 的两个条件
// 1. as 有值, 表示已经发生过竞争, 进入 if
// 2. cas 给 base 累加时失败了, 表示 base 发生了竞争, 进入 if
if ((as = cells) != null || !casBase(b = base, b + x)) {
// uncontended 表示 cell 没有竞争
boolean uncontended = true;
if (
// as 还没有创建
as == null || (m = as.length - 1) < 0 ||
// 当前线程对应的 cell 还没有
(a = as[getProbe() & m]) == null ||
// cas 给当前线程的 cell 累加失败 uncontended=false ( a 为当前线程的 cell )
!(uncontended = a.cas(v = a.value, v + x)))
// 进入 cell 数组创建、cell 创建的流程
longAccumulate(x, null, uncontended);
}
}

add 流程图:

image-20210807040735271

说明:

  • cells是懒惰式创建的,当有竞争的才会创建cells数组,进而创建cells数组里面的cell对象
  • 当cells为空是说明当前竞争并不激烈,累加操作交给base去操作
    • 成功:返回
    • 失败:进入longAccumulate()方法
  • 当cells不为空说明当前存在竞争,查看当前线程cell是否创建
    • 没创建:进入longAccumulate()方法创建cell
    • 创建:累加操作交给创建的cell去操作
      • 成功:返回
      • 失败:进入longAccumulate()方法

由此刻看出,当累加失败或者没有创建cell时都会调用longAccumulate()方法,以下为longAccumulate()方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
// 当前线程还没有对应的 cell, 需要随机生成一个 h 值用来将当前线程绑定到 cell
if ((h = getProbe()) == 0) {
// 初始化 probe
ThreadLocalRandom.current(); // force initialization
// h 对应新的 probe 值, 用来对应 cell
h = getProbe();
wasUncontended = true;
}
// collide 为 true 表示需要扩容
boolean collide = false; // True if last slot nonempty
for (;;) {
Cell[] as; Cell a; int n; long v;
// 已经有了 cells
if ((as = cells) != null && (n = as.length) > 0) {
// 还没有 cell
if ((a = as[(n - 1) & h]) == null) {
// 为 cellsBusy 加锁, 创建 cell, cell 的初始累加值为 x
// 成功则 break, 否则继续 continue 循环
if (cellsBusy == 0) { // Try to attach new Cell
// 创建cell对象
Cell r = new Cell(x); // Optimistically create
// 上锁
if (cellsBusy == 0 && casCellsBusy()) {
boolean created = false;
try { // Recheck under lock
Cell[] rs; int m, j;
if ((rs = cells) != null && // cells数组不为空
(m = rs.length) > 0 && // cells大小大于0
rs[j = (m - 1) & h] == null) { // cells是否有空槽位
// 如果槽位为空,则将创建的cell设置到空槽位当中
rs[j] = r;
created = true;
}
} finally {
// 解锁
cellsBusy = 0;
}
// 创建成功
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
// 有竞争, 改变线程对应的 cell 来重试 cas
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
try {
if (cells == as) { // Expand table unless stale
Cell[] rs = new Cell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
cells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 改变线程对应的 cell
h = advanceProbe(h);
}
/*
* 三个判断:
* 1、判断cellsBusy锁是否上锁
* 2、是否有其他线程创建了cells
* 3、尝试对cellsBusy上锁:把cellsBusy的值从0改到1
*/
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
// 加锁成功, 初始化 cells, 最开始长度为 2, 并填充一个 cell
// 成功则 break;
boolean init = false;
try { // Initialize table
// 再次判断是否其他线程已经了创建cells
if (cells == as) {
// 创建cells,初始大小是2,
// 但是同时创建了一个cell(只创建了一个cell,有一个cells的空间是空的)
// 线程不到万不得已才会使用到这个空的cell,体现了:线程对cell的懒惰初始化
Cell[] rs = new Cell[2];
rs[h & 1] = new Cell(x);
cells = rs;
init = true;
}
} finally {
// 解锁
cellsBusy = 0;
}
// 初始化成功
if (init)
break;
}
// 上两种情况失败, 尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break; // Fall back on using base
}
}

longAccumulate流程图:

1
2
3
4
5
6
// 加锁成功,进入下面else if块的逻辑:创建cells
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {...}

// 加锁失败,进入下面else if块的逻辑:尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))

image-20210807042131270

1
2
// cells不为空且cells的长度大于0:创建cell
if ((as = cells) != null && (n = as.length) > 0) {...}

image-20210807042151516

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x :
fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false; // At max size or stale
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
try {
if (cells == as) { // Expand table unless stale
// 先将长度进行翻倍 n<<1
Cell[] rs = new Cell[n << 1];
// 把老数组的对象复制到新数组当中
for (int i = 0; i < n; ++i)
rs[i] = as[i];
// 用新数组替换掉旧数组
cells = rs;
}
} finally {
// 解锁
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 改变线程对应的 cell
h = advanceProbe(h);

每个线程刚进入 longAccumulate 时,会尝试对应一个 cell 对象(找到一个坑位)

image-20210807042217185

获取最终结果通过 sum 方法:

1
2
3
4
5
6
7
8
9
10
11
public long sum() {
Cell[] as = cells; Cell a;
long sum = base;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}

6、再说AutomicStampedReference解决CAS的ABA问题

1、AutomicStampedReference解决CAS的ABA问题

AtomicStampedReference主要维护包含一个对象引用以及一个可以自动更新的整数”stamp”的pair对象来解决ABA问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference; //维护对象引用
final int stamp; //用于标志版本
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
....

/**
* expectedReference :更新之前的原始值
* newReference : 将要更新的新值
* expectedStamp : 期待更新的标志版本
* newStamp : 将要更新的标志版本
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
// 获取当前的(元素值,版本号)对
Pair<V> current = pair;
return
// 引用没变
expectedReference == current.reference &&
// 版本号没变
expectedStamp == current.stamp &&
// 新引用等于旧引用
((newReference == current.reference &&
// 新版本号等于旧版本号
newStamp == current.stamp) ||
// 构造新的Pair对象并CAS更新
casPair(current, Pair.of(newReference, newStamp)));
}

private boolean casPair(Pair<V> cmp, Pair<V> val) {
// 调用Unsafe的compareAndSwapObject()方法CAS更新pair的引用为新引用
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
}
  • 如果元素值和版本号都没有变化,并且和新的也相同,返回true;
  • 如果元素值和版本号都没有变化,并且和新的不完全相同,就构造一个新的Pair对象并执行CAS更新pair。

可以看到,java中的实现跟我们上面讲的ABA的解决方法是一致的。

  • 首先,使用版本号控制;
  • 其次,不重复使用节点(Pair)的引用,每次都新建一个新的Pair来作为CAS比较的对象,而不是复用旧的;
  • 最后,外部传入元素值及版本号,而不是节点(Pair)的引用。
2、使用举例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private static AtomicStampedReference<Integer> atomicStampedRef =
new AtomicStampedReference<>(1, 0);
public static void main(String[] args){
Thread main = new Thread(() -> {
System.out.println("操作线程" + Thread.currentThread() +",初始值 a = " + atomicStampedRef.getReference());
int stamp = atomicStampedRef.getStamp(); //获取当前标识别
try {
Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean isCASSuccess = atomicStampedRef.compareAndSet(1,2,stamp,stamp +1); //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
System.out.println("操作线程" + Thread.currentThread() +",CAS操作结果: " + isCASSuccess);
},"主操作线程");

Thread other = new Thread(() -> {
Thread.yield(); // 确保thread-main 优先执行
atomicStampedRef.compareAndSet(1,2,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【increment】 ,值 = "+ atomicStampedRef.getReference());
atomicStampedRef.compareAndSet(2,1,atomicStampedRef.getStamp(),atomicStampedRef.getStamp() +1);
System.out.println("操作线程" + Thread.currentThread() +",【decrement】 ,值 = "+ atomicStampedRef.getReference());
},"干扰线程");

main.start();
other.start();
}
1
2
3
4
5
// 输出
> 操作线程Thread[主操作线程,5,main],初始值 a = 2
> 操作线程Thread[干扰线程,5,main],【increment】 ,值 = 2
> 操作线程Thread[干扰线程,5,main],【decrement】 ,值 = 1
> 操作线程Thread[主操作线程,5,main],CAS操作结果: false
3、java中还有哪些类可以解决ABA问题?

AtomicMarkableReference,它不是维护一个版本号,而是维护一个boolean类型的标记,标记值有修改,了解一下。

4、在日常的业务中怎么解决ABA问题?(用乐观锁的方法)
  • 加标志位做版本号,例如搞个自增的字段,操作一次就自增加一;
  • 加个时间戳,比较时间戳的值

3、JUC锁:LockSupport详解

LockSupport是锁中的基础,是一个提供锁机制的工具类。

1、BAT大厂的面试问题

  • 为什么LockSupport也是核心基础类?
    • AQS框架借助于两个类:Unsafe(提供CAS操作)LockSupport(提供park/unpark操作)
  • 写出分别通过wait/notify和LockSupport的park/unpark实现同步?
  • LockSupport.park()会释放锁资源吗?那么Condition.await()呢?
  • Thread.sleep()、Object.wait()、Condition.await()、LockSupport.park()的区别?重点
  • 如果在wait()之前执行了notify()会怎样?
  • 如果在park()之前执行了unpark()会怎样?

2、LockSupport简介

LockSupport用来创建锁和其他同步类的基本线程阻塞原语。简而言之,当调用LockSupport.park时,表示当前线程将会等待,直至获得许可,当调用LockSupport.unpark时,必须把等待获得许可的线程作为参数进行传递,好让此线程继续运行。

3、LockSupport源码分析

1、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class LockSupport {
// Hotspot implementation via intrinsics API
private static final sun.misc.Unsafe UNSAFE;
// 表示内存偏移地址
private static final long parkBlockerOffset;
// 表示内存偏移地址
private static final long SEED;
// 表示内存偏移地址
private static final long PROBE;
// 表示内存偏移地址
private static final long SECONDARY;

static {
try {
// 获取Unsafe实例
UNSAFE = sun.misc.Unsafe.getUnsafe();
// 线程类类型
Class<?> tk = Thread.class;
// 获取Thread的parkBlocker字段的内存偏移地址
parkBlockerOffset = UNSAFE.objectFieldOffset
(tk.getDeclaredField("parkBlocker"));
// 获取Thread的threadLocalRandomSeed字段的内存偏移地址
SEED = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSeed"));
// 获取Thread的threadLocalRandomProbe字段的内存偏移地址
PROBE = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomProbe"));
// 获取Thread的threadLocalRandomSecondarySeed字段的内存偏移地址
SECONDARY = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSecondarySeed"));
} catch (Exception ex) { throw new Error(ex); }
}
}

说明:UNSAFE字段表示sun.misc.Unsafe类,查看其源码,点击在这里,一般程序中不允许直接调用,而long型的表示实例对象相应字段在内存中的偏移地址,可以通过该偏移地址获取或者设置该字段的值。

2、类的构造函数
1
2
// 私有构造函数,无法被实例化
private LockSupport() {}

说明:LockSupport只有一个私有构造函数,无法被实例化。

3、核心函数分析

在分析LockSupport函数之前,先引入sun.misc.Unsafe类中的park和unpark函数,因为LockSupport的核心函数都是基于Unsafe类中定义的park和unpark函数,下面给出两个函数的定义:

1
2
public native void park(boolean isAbsolute, long time);
public native void unpark(Thread thread);

说明:对两个函数的说明如下:

  • park函数,阻塞线程,并且该线程在下列情况发生之前都会被阻塞:
    1. 调用unpark函数,释放该线程的许可。
    2. 该线程被中断。
    3. 设置的时间到了。并且,当time为绝对时间时,isAbsolute为true,否则,isAbsolute为false。当time为0时,表示无限等待,直到unpark发生。
  • unpark函数,释放线程的许可,即激活调用park后阻塞的线程。这个函数不是安全的,调用这个函数时要确保线程依旧存活。
1、park函数

park函数有两个重载版本,方法摘要如下:

1
2
public static void park()
public static void park(Object blocker)

说明:两个函数的区别在于park()函数没有没有blocker,即没有设置线程的parkBlocker字段。park(Object)型函数如下:

1
2
3
4
5
6
7
8
9
10
public static void park(Object blocker) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可
UNSAFE.park(false, 0L);
// 重新可运行后再此设置Blocker
setBlocker(t, null);
}

说明:调用park函数时,首先获取当前线程,然后设置当前线程的parkBlocker字段,即调用setBlocker函数,之后调用Unsafe类的park函数,之后再调用setBlocker函数。

那么问题来了,为什么要在此park函数中要调用两次setBlocker函数呢?

原因其实很简单,调用park函数时,当前线程首先设置好parkBlocker字段,然后再调用Unsafe的park函数,此后,当前线程就已经阻塞了,等待该线程的unpark函数被调用,所以后面的一个setBlocker函数无法运行,unpark函数被调用,该线程获得许可后,就可以继续运行了,也就运行第二个setBlocker,把该线程的parkBlocker字段设置为null,这样就完成了整个park函数的逻辑。

如果没有第二个setBlocker,那么之后没有调用park(Object blocker),而直接调用getBlocker函数,得到的还是前一个park(Object blocker)设置的blocker,显然是不符合逻辑的。总之,必须要保证在park(Object blocker)整个函数执行完后,该线程的parkBlocker字段又恢复为null。所以,park(Object)型函数里必须要调用setBlocker函数两次。

setBlocker方法如下:

1
2
3
4
private static void setBlocker(Thread t, Object arg) {
// 设置线程t的parkBlocker字段的值为arg
UNSAFE.putObject(t, parkBlockerOffset, arg);
}

说明:此方法用于设置线程t的parkBlocker字段的值为arg。

另外一个无参重载版本,park()函数如下:

1
2
3
4
public static void park() {
// 获取许可,设置时间为无限长,直到可以获取许可
UNSAFE.park(false, 0L);
}

说明:调用了park函数后,会禁用当前线程,除非许可可用。在以下三种情况之一发生之前,当前线程都将处于休眠状态,即下列情况发生时,当前线程会获取许可,可以继续运行:

  • 其他某个线程将当前线程作为目标调用 unpark。
  • 其他某个线程中断当前线程。
  • 该调用不合逻辑地(即毫无理由地)返回。
2、parkNanos函数

此函数表示在许可可用前禁用当前线程,并最多等待指定的等待时间。具体函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
public static void parkNanos(Object blocker, long nanos) {
if (nanos > 0) { // 时间大于0
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可,并设置了时间
UNSAFE.park(false, nanos);
// 设置许可
setBlocker(t, null);
}
}

说明:该函数也是调用了两次setBlocker函数,nanos参数表示相对时间,表示等待多长时间。

3、parkUntil函数

此函数表示在指定的时限前禁用当前线程,除非许可可用,具体函数如下:

1
2
3
4
5
6
7
8
9
public static void parkUntil(Object blocker, long deadline) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
UNSAFE.park(true, deadline);
// 设置Blocker为null
setBlocker(t, null);
}

说明:该函数也调用了两次setBlocker函数,deadline参数表示绝对时间,表示指定的时间。

4、unpark函数

此函数表示如果给定线程的许可尚不可用,则使其可用。如果线程在 park 上受阻塞,则它将解除其阻塞状态。否则,保证下一次调用 park 不会受阻塞。如果给定线程尚未启动,则无法保证此操作有任何效果。具体函数如下:

1
2
3
4
public static void unpark(Thread thread) {
if (thread != null) // 线程为不空
UNSAFE.unpark(thread); // 释放该线程许可
}

说明:释放许可,指定线程可以继续运行。

4、park/unpark 原理

每个线程都有自己的一个 Parker 对象(有C++编写),由三部分组成 _counter_cond_mutex 打个比喻

  • 线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  • 调用 park 就是要看需不需要停下来歇息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用 unpark,就好比令干粮充足
    • 如果这时线程还在帐篷,就唤醒让他继续前进
    • 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进
      • 因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
1、先调用park()

image-20210806000710338

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0
2、再调用unpark():

image-20210806000820108

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为 0
3、先调用unpark(),再调用park():

image-20210806000922500

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 当前线程调用 Unsafe.park() 方法
  3. 检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
  4. 设置 _counter 为 0

5、LockSupport示例说明

1、使用wait/notify实现线程同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyThread extends Thread {
@Override
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}

public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
synchronized (myThread) {
try {
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

运行结果:

1
2
3
4
before wait
before notify
after notify
after wait

说明:具体的流程图如下:

img

使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyThread extends Thread {
@Override
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}

public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
synchronized (myThread) {
try {
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

运行结果:

1
2
3
before notify
after notify
before wait

说明:由于先调用了notify,再调用的wait,此时主线程还是会一直阻塞。

2、使用park/unpark实现线程同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before unpark");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));
// 释放许可
LockSupport.unpark((Thread) object);
// 休眠500ms,保证先执行park中的setBlocker(t, null);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 再次获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));

System.out.println("after unpark");
}
}

public class test {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
5
6
before park
before unpark
Blocker info ParkAndUnparkDemo
after park
Blocker info null
after unpark

说明:本程序先执行park,然后在执行unpark,进行同步,并且在unpark的前后都调用了getBlocker,可以看到两次的结果不一样,并且第二次调用的结果为null,这是因为在调用unpark之后,执行了Lock.park(Object blocker)函数中的setBlocker(t, null)函数,所以第二次调用getBlocker时为null。

上例是先调用park,然后调用unpark,现在修改程序,先调用unpark,然后调用park,看能不能正确同步。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before unpark");
// 释放许可
LockSupport.unpark((Thread) object);
System.out.println("after unpark");
}
}

public class ParkAndUnparkDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
try {
// 主线程睡眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
before unpark
after unpark
before park
after park

说明:可以看到,在先调用unpark,再调用park时,仍能够正确实现同步,不会造成由wait/notify调用顺序不当所引起的阻塞。因此park/unpark相比wait/notify更加的灵活。

3、中断响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.concurrent.locks.LockSupport;

class MyThread extends Thread {
private Object object;

public MyThread(Object object) {
this.object = object;
}

@Override
public void run() {
System.out.println("before interrupt");
try {
// 休眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread = (Thread) object;
// 中断线程
thread.interrupt();
System.out.println("after interrupt");
}
}

public class InterruptDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}

运行结果:

1
2
3
4
before park
before interrupt
after interrupt
after park

说明:可以看到,在主线程调用park阻塞后,在myThread线程中发出了中断信号,此时主线程会继续运行,也就是说明此时interrupt起到的作用与unpark一样。

6、更深入的理解

1、Thread.sleep()和Object.wait()的区别

首先,我们先来看看Thread.sleep()和Object.wait()的区别,这是一个烂大街的题目了,大家应该都能说上来两点:

  • Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁;
  • Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去;
  • Thread.sleep()到时间了会自动唤醒,然后继续执行;
  • Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒;
  • Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁;

其实,他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。

2、Thread.sleep()和Condition.await()的区别

Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。

实际上,它在阻塞当前线程之前还干了两件事:

  1. 一是把当前线程添加到条件队列中
  2. 二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。
3、Thread.sleep()和LockSupport.park()的区别

LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。

  • 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
  • Thread.sleep()没法从外部唤醒,只能自己醒过来;
  • LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
  • Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出;
  • LockSupport.park()方法不需要捕获中断异常;
  • Thread.sleep()本身就是一个native方法;
  • LockSupport.park()底层是调用的Unsafe的native方法;
4、Object.wait()和LockSupport.park()的区别

二者都会阻塞当前线程的运行,他们有什么区别呢? 经过上面的分析相信你一定很清楚了,真的吗? 往下看!

  • Object.wait()方法需要在synchronized块中执行;
  • LockSupport.park()可以在任意地方执行;
  • Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出;
  • LockSupport.park()不需要捕获中断异常;
  • Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;
  • LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
  • 如果在wait()之前执行了notify()会怎样? 抛出IllegalMonitorStateException异常;
  • 如果在park()之前执行了unpark()会怎样? 线程不会被阻塞,直接跳过park(),继续执行后续内容;

park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。

5、LockSupport.park()会释放锁资源吗?

不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。

4、AbstractQueuedSynchronizer(AQS)

AbstractQueuedSynchronizer抽象类是核心,需要重点掌握。它提供了一个基于FIFO队列,可以用于构建锁或者其他相关同步装置的基础框架。

1、BAT大厂的面试问题

  • 什么是AQS?为什么它是核心?
  • AQS的核心思想是什么?它是怎么实现的?底层数据结构等
  • AQS有哪些核心的方法?
  • AQS定义什么样的资源获取方式?
    • AQS定义了两种资源获取方式:
      • 独占(只有一个线程能访问执行,又根据是否按队列的顺序分为公平锁非公平锁,如ReentrantLock)
      • 共享(多个线程可同时访问执行,如SemaphoreCountDownLatchCyclicBarrier )。ReentrantReadWriteLock可以看成是组合式,允许多个线程同时对某一资源进行读。
  • AQS底层使用了什么样的设计模式?
    • 模板
  • AQS的应用示例?

2、AbstractQueuedSynchronizer简介

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

1、AQS核心思想

AQS核心思想是:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。

AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。

AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

1
private volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过protected类型的getState,setState,compareAndSetState进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
2、AQS对资源的共享方式

AQS定义两种资源共享方式:

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。
    • 又可分为公平锁和非公平锁:
      • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
      • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的(抢不到就乖乖排队吧)
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

3、AQS底层使用了模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用)

使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放) 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这和我们以往通过实现接口的方式有很大区别。

AQS使用了模板方法模式,自定义同步器时需要重写下面几个AQS提供的模板方法:

1
2
3
4
5
isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。

以ReentrantLock为例:

  1. state初始化为0,表示未锁定状态。
  2. A线程lock()时,会调用tryAcquire()独占该锁并将state+1。
  3. 此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。
  4. 当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
4、总结

AbstractQueuedSynchronizer是阻塞式锁和相关的同步器工具的框架,特点:

  • 用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
    • getState - 获取 state 状态
    • setState - 设置 state 状态
    • compareAndSetState - cas 机制设置 state 状态
    • 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
  • 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet
  • 子类主要实现这样一些方法(默认抛出 UnsupportedOperationException
    • tryAcquire
    • tryRelease
    • tryAcquireShared
    • tryReleaseShared
    • isHeldExclusively

3、AbstractQueuedSynchronizer数据结构

AbstractQueuedSynchronizer类底层的数据结构是使用CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配

  • 其中Sync queue,即同步队列,是双向链表,包括head结点和tail结点,head结点主要用作后续的调度
  • Condition queue不是必须的,其是一个单向链表,只有当使用Condition时,才会存在此单向链表。并且可能会有多个Condition queue

image

4、AbstractQueuedSynchronizer源码分析

1、类的继承关系

AbstractQueuedSynchronizer继承自AbstractOwnableSynchronizer抽象类,并且实现了Serializable接口,可以进行序列化。

1
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable

其中AbstractOwnableSynchronizer抽象类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {

// 版本序列号
private static final long serialVersionUID = 3737899427754241961L;
// 构造方法
protected AbstractOwnableSynchronizer() { }
// 独占模式下的线程
private transient Thread exclusiveOwnerThread;

// 设置独占线程
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}

// 获取独占线程
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}

AbstractOwnableSynchronizer抽象类中,可以设置独占资源线程和获取独占资源线程。分别为setExclusiveOwnerThread与getExclusiveOwnerThread方法,这两个方法会被子类调用。

2、类的内部类

AbstractQueuedSynchronizer类有两个内部类,分别为Node类与ConditionObject类。

3、类的内部类——Node类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
static final class Node {
// 模式,分为共享与独占
// 共享模式
static final Node SHARED = new Node();
// 独占模式
static final Node EXCLUSIVE = null;
// 结点状态
// CANCELLED,值为1,表示当前的线程被取消
// SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark
// CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中
// PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行
// 值为0,表示当前节点在sync队列中,等待着获取锁
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;

// 结点状态
volatile int waitStatus;
// 前驱结点
volatile Node prev;
// 后继结点
volatile Node next;
// 结点所对应的线程
volatile Thread thread;
// 下一个等待者
Node nextWaiter;

// 结点是否在共享模式下等待
final boolean isShared() {
return nextWaiter == SHARED;
}

// 获取前驱结点,若前驱结点为空,抛出异常
final Node predecessor() throws NullPointerException {
// 保存前驱结点
Node p = prev;
if (p == null) // 前驱结点为空,抛出异常
throw new NullPointerException();
else // 前驱结点不为空,返回
return p;
}

// 无参构造方法
Node() { // Used to establish initial head or SHARED marker
}

// 构造方法
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}

// 构造方法
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}

每个线程被阻塞的线程都会被封装成一个Node结点,放入队列。每个节点包含了一个Thread类型的引用,并且每个节点都存在一个状态,具体状态如下:

  • CANCELLED,值为1,表示当前的线程被取消。
  • SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,需要进行unpark操作。
  • CONDITION,值为-2,表示当前节点在等待condition,也就是在condition queue中。
  • PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行。
  • 值为0,表示当前节点在sync queue中,等待着获取锁。
4、类的内部类——ConditionObject类

这个类有点长,耐心看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
// 内部类
public class ConditionObject implements Condition, java.io.Serializable {
// 版本号
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
// condition队列的头结点
// 第一个等待节点
private transient Node firstWaiter;
/** Last node of condition queue. */
// condition队列的尾结点
// 最后一个等待节点
private transient Node lastWaiter;

/**
* Creates a new {@code ConditionObject} instance.
*/
// 构造方法
public ConditionObject() { }

// Internal methods

/**
* Adds a new waiter to wait queue.
* @return its new wait node
*/
// 添加新的Node(waiter)到wait队列
private Node addConditionWaiter() {
// 保存尾结点
Node t = lastWaiter;
// If lastWaiter is cancelled, clean out.
// 所有已取消的 Node 从队列链表删除
if (t != null && t.waitStatus != Node.CONDITION) { // 尾结点不为空,并且尾结点的状态不为CONDITION
// 清除状态为CONDITION的结点
unlinkCancelledWaiters();
// 将最后一个结点重新赋值给t
t = lastWaiter;
}
// 创建一个关联当前线程的新 Node, 添加至队列尾部
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null) // 尾结点为空
// 设置condition队列的头结点
firstWaiter = node;
else // 尾结点不为空
// 设置为节点的nextWaiter域为node结点
t.nextWaiter = node;
// 更新condition队列的尾结点
lastWaiter = node;
return node;
}

/**
* Removes and transfers nodes until hit non-cancelled one or
* null. Split out from signal in part to encourage compilers
* to inline the case of no waiters.
* @param first (non-null) the first node on condition queue
*/
// 唤醒 - 将没取消的第一个节点转移至 AQS 队列
private void doSignal(Node first) {
// 循环
do {
// 已经是尾节点了
if ( (firstWaiter = first.nextWaiter) == null) // 该节点的nextWaiter为空
// 设置尾结点为空
lastWaiter = null;
// 设置first结点的nextWaiter域
first.nextWaiter = null;
} while (// 将结点从condition队列转移到sync队列失败并且condition队列中的头结点不为空,一直循环
// 将等待队列中的 Node 转移至 AQS 队列, 不成功且还有节点则继续循环
!transferForSignal(first) &&
// 队列还有节点
(first = firstWaiter) != null);
}

/**
* Removes and transfers all nodes.
* @param first (non-null) the first node on condition queue
*/
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}

/**
* Unlinks cancelled waiter nodes from condition queue.
* Called only while holding lock. This is called when
* cancellation occurred during condition wait, and upon
* insertion of a new waiter when lastWaiter is seen to have
* been cancelled. This method is needed to avoid garbage
* retention in the absence of signals. So even though it may
* require a full traversal, it comes into play only when
* timeouts or cancellations occur in the absence of
* signals. It traverses all nodes rather than stopping at a
* particular target to unlink all pointers to garbage nodes
* without requiring many re-traversals during cancellation
* storms.
*/
// 从condition队列中清除状态为CANCEL的结点
private void unlinkCancelledWaiters() {
// 保存condition队列头结点
Node t = firstWaiter;
Node trail = null;
while (t != null) { // t不为空
// 下一个结点
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) { // t结点的状态不为CONDTION状态
// 设置t节点的nextWaiter域为空
t.nextWaiter = null;
if (trail == null) // trail为空
// 重新设置condition队列的头结点
firstWaiter = next;
else // trail不为空
// 设置trail结点的nextWaiter域为next结点
trail.nextWaiter = next;
if (next == null) // next结点为空
// 设置condition队列的尾结点
lastWaiter = trail;
}
else // t结点的状态为CONDTION状态
// 设置trail结点
trail = t;
// 设置t结点
t = next;
}
}

// public methods

/**
* Moves the longest-waiting thread, if one exists, from the
* wait queue for this condition to the wait queue for the
* owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
// 唤醒 - 必须持有锁才能唤醒, 因此 doSignal 内无需考虑加锁
public final void signal() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒一个等待线程
doSignal(first);
}

/**
* Moves all threads from the wait queue for this condition to
* the wait queue for the owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
// 全部唤醒 - 必须持有锁才能唤醒, 因此 doSignalAll 内无需考虑加锁
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}

/**
* Implements uninterruptible condition wait.
* <ol>
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* </ol>
*/
// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
// 不可打断等待 - 直到被唤醒
public final void awaitUninterruptibly() {
// 添加一个结点到等待队列
Node node = addConditionWaiter();
// 获取释放的状态,释放节点持有的锁
int savedState = fullyRelease(node);
boolean interrupted = false;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) { // 判断当前结点在不在同步队列之中
// 阻塞当前线程
LockSupport.park(this);
// 如果被打断, 仅设置打断状态
if (Thread.interrupted()) // 当前线程被中断
// 设置interrupted状态
interrupted = true;
}
// 唤醒后, 尝试竞争锁, 如果失败进入 AQS 队列
if (acquireQueued(node, savedState) || interrupted) //
selfInterrupt();
}

/*
* For interruptible waits, we need to track whether to throw
* InterruptedException, if interrupted while blocked on
* condition, versus reinterrupt current thread, if
* interrupted while blocked waiting to re-acquire.
*/

/** Mode meaning to reinterrupt on exit from wait */
// 打断模式 - 在退出等待时重新设置打断状态
private static final int REINTERRUPT = 1;
/** Mode meaning to throw InterruptedException on exit from wait */
// 打断模式 - 在退出等待时抛出异常
private static final int THROW_IE = -1;

/**
* Checks for interrupt, returning THROW_IE if interrupted
* before signalled, REINTERRUPT if after signalled, or
* 0 if not interrupted.
*/
// 判断打断模式
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}

/**
* Throws InterruptedException, reinterrupts current thread, or
* does nothing, depending on mode.
*/
// 应用打断模式
private void reportInterruptAfterWait(int interruptMode)
throws InterruptedException {
if (interruptMode == THROW_IE)
throw new InterruptedException();
else if (interruptMode == REINTERRUPT)
selfInterrupt();
}

/**
* Implements interruptible condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled or interrupted.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号或被中断之前一直处于等待状态
// 等待 - 直到被唤醒或打断
public final void await() throws InterruptedException {
if (Thread.interrupted()) // 当前线程被中断,抛出异常
throw new InterruptedException();
// 在wait队列上添加一个结点
// 添加一个 Node 至等待队列
Node node = addConditionWaiter();
// 获取释放的状态
// 释放节点持有的锁
int savedState = fullyRelease(node);
int interruptMode = 0;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) {
// 阻塞当前线程
LockSupport.park(this);
// 如果被打断, 退出等待队列
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) // 检查结点等待时的中断类型
break;
}
// 退出等待队列后, 还需要获得 AQS 队列的锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 所有已取消的 Node 从队列链表删除
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
// 应用打断模式
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}

/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
// 等待 - 直到被唤醒或打断或超时
public final long awaitNanos(long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 添加一个 Node 至等待队列
Node node = addConditionWaiter();
// 释放节点持有的锁
int savedState = fullyRelease(node);
// 获得最后期限
final long deadline = System.nanoTime() + nanosTimeout;
int interruptMode = 0;
// 如果该节点还没有转移至 AQS 队列, 阻塞
while (!isOnSyncQueue(node)) {
// 已超时, 退出等待队列
if (nanosTimeout <= 0L) {
transferAfterCancelledWait(node);
break;
}
// park 阻塞一定时间, spinForTimeoutThreshold 为 1000 ns
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
// 如果被打断, 退出等待队列
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
// 退出等待队列后, 还需要获得 AQS 队列的锁
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
// 所有已取消的 Node 从队列链表删除
if (node.nextWaiter != null)
unlinkCancelledWaiters();
// 应用打断模式
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return deadline - System.nanoTime();
}

/**
* Implements absolute timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
// 等待 - 直到被唤醒或打断或超时, 逻辑类似于 awaitNanos
public final boolean awaitUntil(Date deadline)
throws InterruptedException {
long abstime = deadline.getTime();
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (System.currentTimeMillis() > abstime) {
timedout = transferAfterCancelledWait(node);
break;
}
LockSupport.parkUntil(this, abstime);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}

/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
// 等待 - 直到被唤醒或打断或超时, 逻辑类似于 awaitNanos
public final boolean await(long time, TimeUnit unit)
throws InterruptedException {
long nanosTimeout = unit.toNanos(time);
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
final long deadline = System.nanoTime() + nanosTimeout;
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (nanosTimeout <= 0L) {
timedout = transferAfterCancelledWait(node);
break;
}
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}

// support for instrumentation

/**
* Returns true if this condition was created by the given
* synchronization object.
*
* @return {@code true} if owned
*/
final boolean isOwnedBy(AbstractQueuedSynchronizer sync) {
return sync == AbstractQueuedSynchronizer.this;
}

/**
* Queries whether any threads are waiting on this condition.
* Implements {@link AbstractQueuedSynchronizer#hasWaiters(ConditionObject)}.
*
* @return {@code true} if there are any waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 查询是否有正在等待此条件的任何线程
protected final boolean hasWaiters() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
return true;
}
return false;
}

/**
* Returns an estimate of the number of threads waiting on
* this condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitQueueLength(ConditionObject)}.
*
* @return the estimated number of waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回正在等待此条件的线程数估计值
protected final int getWaitQueueLength() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int n = 0;
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
++n;
}
return n;
}

/**
* Returns a collection containing those threads that may be
* waiting on this Condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitingThreads(ConditionObject)}.
*
* @return the collection of threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回包含那些可能正在等待此条件的线程集合
protected final Collection<Thread> getWaitingThreads() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
ArrayList<Thread> list = new ArrayList<Thread>();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION) {
Thread t = w.thread;
if (t != null)
list.add(t);
}
}
return list;
}
}

此类实现了Condition接口,Condition接口定义了条件操作规范,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface Condition {

// 等待,当前线程在接到信号或被中断之前一直处于等待状态
void await() throws InterruptedException;

// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
void awaitUninterruptibly();

//等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
long awaitNanos(long nanosTimeout) throws InterruptedException;

// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
boolean await(long time, TimeUnit unit) throws InterruptedException;

// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
boolean awaitUntil(Date deadline) throws InterruptedException;

// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
void signal();

// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
void signalAll();
}

Condition接口中定义了await、signal方法,用来等待条件、释放条件。之后会详细分析CondtionObject的源码。

5、类的属性

属性中包含了头结点head尾结点tail状态state自旋时间spinForTimeoutThreshold,还有AbstractQueuedSynchronizer抽象的属性在内存中的偏移地址,通过该偏移地址,可以获取和设置该属性的值,同时还包括一个静态初始化块,用于加载内存偏移地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = 7373984972572414691L;
// 头结点
private transient volatile Node head;
// 尾结点
private transient volatile Node tail;
// 状态
private volatile int state;
// 自旋时间
static final long spinForTimeoutThreshold = 1000L;

// Unsafe类实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
// state内存偏移地址
private static final long stateOffset;
// head内存偏移地址
private static final long headOffset;
// state内存偏移地址
private static final long tailOffset;
// tail内存偏移地址
private static final long waitStatusOffset;
// next内存偏移地址
private static final long nextOffset;
// 静态初始化块
static {
try {
stateOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
headOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
tailOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
waitStatusOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("waitStatus"));
nextOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("next"));
} catch (Exception ex) { throw new Error(ex); }
}
}
6、类的构造方法

此类构造方法为从抽象构造方法,供子类调用:

1
protected AbstractQueuedSynchronizer() {}
7、类的核心方法——acquire方法

该方法以独占模式获取(资源),忽略中断,即线程在aquire过程中,中断此线程是无效的。源码如下:

1
2
3
4
5
6
7
8
9
10
public final void acquire(int arg) {
// 当 tryAcquire 返回为 false 时, 先调用AQS的addWaiter , 接着 acquireQueued
if (
// 尝试获得写锁失败
!tryAcquire(arg) &&
// 将当前线程关联到一个 Node 对象上, 模式为独占模式
// 进入 AQS 队列阻塞
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

由上述源码可以知道,当一个线程调用acquire时,调用方法流程如下:

java-thread-x-juc-aqs-2

  • 首先调用tryAcquire方法,调用此方法的线程会试图在独占模式下获取对象状态。此方法应该查询是否允许它在独占模式下获取对象状态,如果允许,则获取它。在AbstractQueuedSynchronizer源码中默认会抛出一个异常,即需要子类去重写此方法完成自己的逻辑。之后会进行分析。
  • 若tryAcquire失败,则调用addWaiter方法,addWaiter方法完成的功能是将调用此方法的线程封装成为一个结点并放入Sync queue
  • 调用acquireQueued方法,此方法完成的功能是Sync queue中的结点不断尝试获取资源,若成功,则返回true,否则,返回false
  • 由于tryAcquire默认实现是抛出异常,所以此时,不进行分析,之后会结合一个例子进行分析。

首先分析addWaiter方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 添加等待者
private Node addWaiter(Node mode) {
// 将当前线程关联到一个 Node 对象上, 模式为独占模式
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 如果 tail 不为 null, cas 尝试将 Node 对象加入 AQS 队列尾部
Node pred = tail;
if (pred != null) { // 尾结点不为空,即已经被初始化
// 将node结点的prev域连接到尾结点
node.prev = pred;
if (compareAndSetTail(pred, node)) { // 比较pred是否为尾结点,是则将尾结点设置为node
// 双向链表
// 设置尾结点的next域为node
pred.next = node;
return node; // 返回新生成的结点
}
}
// 尝试将 Node 加入 AQS
enq(node); // 尾结点为空(即还没有被初始化过),或者是compareAndSetTail操作失败,则入队列
return node;
}

addWaiter方法使用快速添加的方式往sync queue尾部添加结点,如果sync queue队列还没有初始化,则会使用enq插入队列中,enq方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
// 还没有, 设置 head 为哨兵节点(不对应线程,状态为 0)
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
// cas 尝试将 Node 对象加入 AQS 队列尾部
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}

enq方法会使用无限循环来确保节点的成功插入

现在,分析acquireQueue方法。其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// sync队列中的结点在独占且忽略中断的模式下获取(资源)
final boolean acquireQueued(final Node node, int arg) {
// 标志
boolean failed = true;
try {
// 中断标志
boolean interrupted = false;
for (;;) { // 无限循环
// 获取node节点的前驱结点
final Node p = node.predecessor();
// 上一个节点是 head, 表示轮到自己(当前线程对应的 node)了, 尝试获取
if (p == head && tryAcquire(arg)) { // 前驱为头结点并且成功获得锁
// 获取成功, 设置自己(当前线程对应的 node)为 head
setHead(node); // 设置头结点
// 上一个节点 help GC
p.next = null; // help GC
failed = false; // 设置标志
// 返回中断标记 false
return interrupted;
}
if (// 判断是否应当 park,
shouldParkAfterFailedAcquire(p, node) &&
// park 等待, 此时 Node 的状态被置为 Node.SIGNAL
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}

首先获取当前节点的前驱节点,如果前驱节点是头结点并且能够获取(资源),代表该当前节点能够占有锁,设置头结点为当前节点,返回。否则,调用shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,首先,我们看shouldParkAfterFailedAcquire方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 当获取(资源)失败后,检查并且更新结点状态
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱结点的状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) // 状态为SIGNAL,为-1
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
// 前驱节点都在阻塞, 那么自己也阻塞好了
// 可以进行park操作
return true;
// > 0 表示取消状态
if (ws > 0) { // 表示状态为CANCELLED,为1
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
// 上一个节点取消, 那么重构删除前面所有取消的节点, 返回到外层循环重试
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0); // 找到pred结点前面最近的一个状态不为CANCELLED的结点
// 赋值pred结点的next域
pred.next = node;
} else { // 为PROPAGATE -3 或者是0 表示无状态,(为CONDITION -2时,表示此节点在condition queue中)
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
// 这次还没有阻塞
// 但下次如果重试不成功, 则需要阻塞,这时需要设置上一个节点状态为 Node.SIGNAL
// 比较并设置前驱结点的状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 不能进行park操作
return false;
}

只有当该节点的前驱结点的状态为SIGNAL时,才可以对该结点所封装的线程进行park操作。否则,将不能进行park操作。再看parkAndCheckInterrupt方法,源码如下:

1
2
3
4
5
6
// 进行park操作并且返回该线程是否被中断
private final boolean parkAndCheckInterrupt() {
// 在许可可用之前禁用当前线程,并且设置了blocker
LockSupport.park(this);
return Thread.interrupted(); // 当前线程是否已被中断,并清除中断标记位
}

parkAndCheckInterrupt方法里的逻辑是首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。再看final块中的cancelAcquire方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 取消继续获取(资源)
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
// node为空,返回
if (node == null)
return;
// 设置node结点的thread为空
node.thread = null;

// Skip cancelled predecessors
// 保存node的前驱结点
Node pred = node.prev;
while (pred.waitStatus > 0) // 找到node前驱结点中第一个状态小于0的结点,即不为CANCELLED状态的结点
node.prev = pred = pred.prev;

// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
// 获取pred结点的下一个结点
Node predNext = pred.next;

// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
// 设置node结点的状态为CANCELLED
node.waitStatus = Node.CANCELLED;

// If we are the tail, remove ourselves.
if (node == tail && compareAndSetTail(node, pred)) { // node结点为尾结点,则设置尾结点为pred结点
// 比较并设置pred结点的next节点为null
compareAndSetNext(pred, predNext, null);
} else { // node结点不为尾结点,或者比较设置不成功
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) { // (pred结点不为头结点,并且pred结点的状态为SIGNAL)或者
// pred结点状态小于等于0,并且比较并设置等待状态为SIGNAL成功,并且pred结点所封装的线程不为空
// 保存结点的后继
Node next = node.next;
if (next != null && next.waitStatus <= 0) // 后继不为空并且后继的状态小于等于0
compareAndSetNext(pred, predNext, next); // 比较并设置pred.next = next;
} else {
unparkSuccessor(node); // 释放node的前一个结点
}

node.next = node; // help GC
}
}

该方法完成的功能就是取消当前线程对资源的获取,即设置该结点的状态为CANCELLED,接着我们再看unparkSuccessor方法,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 释放后继结点
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
// 获取node结点的等待状态
// 如果状态为 Node.SIGNAL 尝试重置状态为 0
// 不成功也可以
int ws = node.waitStatus;
if (ws < 0) // 状态值小于0,为SIGNAL -1 或 CONDITION -2 或 PROPAGATE -3
// 比较并且设置结点等待状态,设置为0
compareAndSetWaitStatus(node, ws, 0);

/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
// 获取node节点的下一个结点
// 找到需要 unpark 的节点, 但本节点从 AQS 队列中脱离, 是由唤醒节点完成的
Node s = node.next;
// 不考虑已取消的节点, 从 AQS 队列从后至前找到队列最前面需要 unpark 的节点
if (s == null || s.waitStatus > 0) { // 下一个结点为空或者下一个节点的等待状态大于0,即为CANCELLED
// s赋值为空
s = null;
// 从尾结点开始从后往前开始遍历
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0) // 找到等待状态小于等于0的结点,找到最前的状态小于等于0的结点
// 保存结点
s = t;
}
if (s != null) // 该结点不为为空,释放许可
LockSupport.unpark(s.thread);
}

该方法的作用就是为了释放node节点的后继结点

对于cancelAcquire与unparkSuccessor方法,如下示意图可以清晰的表示:

image

其中node为参数,在执行完cancelAcquire方法后的效果就是unpark了s结点所包含的t4线程

现在,再来看acquireQueued方法的整个的逻辑。逻辑如下:

  1. 判断结点的前驱是否为head并且是否成功获取(资源)。
  2. 若步骤1均满足,则设置结点为head,之后会判断是否finally模块,然后返回。
  3. 若步骤2不满足,则判断是否需要park当前线程,是否需要park当前线程的逻辑是判断结点的前驱结点的状态是否为SIGNAL,若是,则park当前结点,否则,不进行park操作。
  4. 若park了当前线程,之后某个线程对本线程unpark后,并且本线程也获得机会运行。那么,将会继续进行步骤①的判断。

注意:

  • 是否需要 unpark 是由当前节点的前驱节点的 waitStatus == Node.SIGNAL 来决定,而不是本节点的 waitStatus 决定
8、类的核心方法——release方法

以独占模式释放对象,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final boolean release(int arg) {
// 尝试释放锁
if (tryRelease(arg)) { // 释放成功
// 保存头结点
// 队列头节点 unpark
Node h = head;
// 队列不为 null
// waitStatus == Node.SIGNAL 才需要 unpark
if (h != null && h.waitStatus != 0) // 头结点不为空并且头结点状态不为0
// unpark AQS 中等待的线程
unparkSuccessor(h); //释放头结点的后继结点
return true;
}
return false;
}

其中,tryRelease的默认实现是抛出异常,需要具体的子类实现,如果tryRelease成功,那么如果头结点不为空并且头结点的状态不为0,则释放头结点的后继结点,unparkSuccessor方法已经分析过,不再累赘。

除了release()方法之外,还有一个方法——fullyRelease()用来释放锁:因为某线程可能重入,需要将 state 全部释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 因为某线程可能重入,需要将 state 全部释放
final int fullyRelease(Node node) {
boolean failed = true;
try {
int savedState = getState(); if (release(savedState)) {
failed = false;
return savedState;
} else {
throw new IllegalMonitorStateException();
}
} finally {
if (failed)
node.waitStatus = Node.CANCELLED;
}
}

对于其他方法我们也可以分析,与前面分析的方法大同小异,所以,不再累赘。

5、AbstractQueuedSynchronizer示例详解一

借助下面示例来分析AbstractQueuedSyncrhonizer内部的工作机制。示例源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}

public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
} finally {
lock.unlock();
}
}
}
public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();

MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
t1.start();
t2.start();
}
}

运行结果(可能的一种):

1
2
Thread[t1,5,main] running
Thread[t2,5,main] running

结果分析:从示例可知,线程t1与t2共用了一把锁,即同一个lock。可能会存在如下一种时序:

image

说明:首先线程t1先执行lock.lock操作,然后t2执行lock.lock操作,然后t1执行lock.unlock操作,最后t2执行lock.unlock操作。基于这样的时序,分析AbstractQueuedSynchronizer内部的工作机制:

  • t1线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:其中,前面的部分表示哪个类,后面是具体的类中的哪个方法,AQS表示AbstractQueuedSynchronizer类,AOS表示AbstractOwnableSynchronizer类。
  • t2线程调用lock.lock方法,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:经过一系列的方法调用,最后达到的状态是禁用t2线程,因为调用了LockSupport.lock。
  • t1线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:t1线程中调用lock.unlock后,经过一系列的调用,最终的状态是释放了许可,因为调用了LockSupport.unpark。这时,t2线程就可以继续运行了。此时,会继续恢复t2线程运行环境,继续执行LockSupport.park后面的语句,即进一步调用如下:
    • image
    • 说明:在上一步调用了LockSupport.unpark后,t2线程恢复运行,则运行parkAndCheckInterrupt,之后,继续运行acquireQueued方法,最后达到的状态是头结点head与尾结点tail均指向了t2线程所在的结点,并且之前的头结点已经从sync队列中断开了。
  • t2线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:t2线程执行lock.unlock后,最终达到的状态还是与之前的状态一样。

6、AbstractQueuedSynchronizer示例详解二

下面我们结合Condition实现生产者与消费者,来进一步分析AbstractQueuedSynchronizer的内部工作机制。

Depot(仓库)类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Depot {
private int size;
private int capacity;
private Lock lock;
private Condition fullCondition;
private Condition emptyCondition;

public Depot(int capacity) {
this.capacity = capacity;
lock = new ReentrantLock();
fullCondition = lock.newCondition();
emptyCondition = lock.newCondition();
}

public void produce(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size >= capacity) {
System.out.println(Thread.currentThread() + " before await");
fullCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int inc = (left + size) > capacity ? (capacity - size) : left;
left -= inc;
size += inc;
System.out.println("produce = " + inc + ", size = " + size);
emptyCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}

public void consume(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size <= 0) {
System.out.println(Thread.currentThread() + " before await");
emptyCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int dec = (size - left) > 0 ? left : size;
left -= dec;
size -= dec;
System.out.println("consume = " + dec + ", size = " + size);
fullCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}

测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Consumer {
private Depot depot;
public Consumer(Depot depot) {
this.depot = depot;
}

public void consume(int no) {
new Thread(new Runnable() {
@Override
public void run() {
depot.consume(no);
}
}, no + " consume thread").start();
}
}

class Producer {
private Depot depot;
public Producer(Depot depot) {
this.depot = depot;
}

public void produce(int no) {
new Thread(new Runnable() {

@Override
public void run() {
depot.produce(no);
}
}, no + " produce thread").start();
}
}

public class ReentrantLockDemo {
public static void main(String[] args) throws InterruptedException {
Depot depot = new Depot(500);
new Producer(depot).produce(500);
new Producer(depot).produce(200);
new Consumer(depot).consume(500);
new Consumer(depot).consume(200);
}
}

运行结果(可能的一种):

1
2
3
4
5
6
7
8
produce = 500, size = 500
Thread[200 produce thread,5,main] before await
consume = 500, size = 0
Thread[200 consume thread,5,main] before await
Thread[200 produce thread,5,main] after await
produce = 200, size = 200
Thread[200 consume thread,5,main] after await
consume = 200, size = 0

说明:根据结果,我们猜测一种可能的时序如下:

image

说明:p1代表produce 500的那个线程,p2代表produce 200的那个线程,c1代表consume 500的那个线程,c2代表consume 200的那个线程。

  1. p1线程调用lock.lock,获得锁,继续运行,方法调用顺序在前面已经给出。
  2. p2线程调用lock.lock,由前面的分析可得到如下的最终状态:
    • java-thread-x-juc-aqs-11
    • 说明:p2线程调用lock.lock后,会禁止p2线程的继续运行,因为执行了LockSupport.park操作。
  3. c1线程调用lock.lock,由前面的分析得到如下的最终状态:
    • image
    • 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含p2的结点)的waitStatus变为了SIGNAL。
  4. c2线程调用lock.lock,由前面的分析得到如下的最终状态:
    • image
    • 说明:最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含c1的结点)的waitStatus变为了SIGNAL。
  5. p1线程执行emptyCondition.signal,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:AQS.CO表示AbstractQueuedSynchronizer.ConditionObject类。此时调用signal方法不会产生任何其他效果。
  6. p1线程执行lock.unlock,根据前面的分析可知,最终的状态如下:
    • image
    • 说明:此时,p2线程所在的结点为头结点,并且其他两个线程(c1、c2)依旧被禁止,所以,此时p2线程继续运行,执行用户逻辑。
  7. p2线程执行fullCondition.await,其方法调用顺序如下,只给出了主要的方法调用:
    • image
    • 说明:最终到达的状态是新生成了一个结点,包含了p2线程,此结点在condition queue中;并且sync queue中p2线程被禁止了,因为在执行了LockSupport.park操作。从方法一些调用可知,在await操作中线程会释放锁资源,供其他线程获取。同时,head结点后继结点的包含的线程的许可被释放了,故其可以继续运行。由于此时,只有c1线程可以运行,故运行c1。
  8. 继续运行c1线程,c1线程由于之前被park了,所以此时恢复,继续之前的步骤,即还是执行前面提到的acquireQueued方法,之后,c1判断自己的前驱结点为head,并且可以获取锁资源,最终到达的状态如下:
    • image
    • 说明:其中,head设置为包含c1线程的结点,c1继续运行。
  9. c1线程执行fullCondtion.signal,其方法调用顺序如下,只给出了主要的方法调用:
    • java-thread-x-juc-aqs-17
    • 说明:signal方法达到的最终结果是将包含p2线程的结点从condition queue中转移到sync queue中,之后condition queue为null,之前的尾结点的状态变为SIGNAL。
  10. c1线程执行lock.unlock操作,根据之前的分析,经历的状态变化如下:
    • image
    • 说明:最终c2线程会获取锁资源,继续运行用户逻辑。
  11. c2线程执行emptyCondition.await,由前面的第七步分析,可知最终的状态如下:
    • image
    • 说明:await操作将会生成一个结点放入condition queue中与之前的一个condition queue是不相同的,并且unpark头结点后面的结点,即包含线程p2的结点。
  12. p2线程被unpark,故可以继续运行,经过CPU调度后,p2继续运行,之后p2线程在AQS:await方法中被park,继续AQS.CO:await方法的运行,其方法调用顺序如下,只给出了主要的方法调用:
    • java-thread-x-juc-aqs-20
  13. p2继续运行,执行emptyCondition.signal,根据第九步分析可知,最终到达的状态如下:
    • java-thread-x-juc-aqs-21
    • 说明:最终,将condition queue中的结点转移到sync queue中,并添加至尾部,condition queue会为空,并且将head的状态设置为SIGNAL。
  14. p2线程执行lock.unlock操作,根据前面的分析可知,最后的到达的状态如下:
    • image
    • 说明: unlock操作会释放c2线程的许可,并且将头结点设置为c2线程所在的结点。
      • c2线程继续运行,执行fullCondition. signal,由于此时fullCondition的condition queue已经不存在任何结点了,故其不会产生作用。
      • c2执行lock.unlock,由于c2是sync队列中最后一个结点,故其不会再调用unparkSuccessor了,直接返回true。即整个流程就完成了。

7、AbstractQueuedSynchronizer总结

对于AbstractQueuedSynchronizer的分析,最核心的就是sync queue的分析。

  • 每一个结点都是由前一个结点唤醒
  • 当结点发现前驱结点是head并且尝试获取成功,则会轮到该线程运行
  • condition queue中的结点向sync queue中转移是通过signal操作完成的
  • 当结点的状态为SIGNAL时,表示后面的结点需要运行

8、使用AQS自定义同步器——实现不可重入锁

自定义锁(不可重入锁):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// 自定义锁(不可重入锁)
class MyLock implements Lock {

// 独占锁 同步器类
class MySync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
if(compareAndSetState(0, 1)) {
// 加上了锁,并设置 owner 为当前线程
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}

@Override
protected boolean tryRelease(int arg) {
// 注意这里:把setState放在后面是因为state是有volatile修饰的,会在setState处放入写屏障
// 用来保证写屏障前面的写操作对其他线程可见
// 而setExclusiveOwnerThread(null);设置的exclusiveOwnerThread没有用volatile修饰
// 所以如果没把setState放在后面,exclusiveOwnerThrea不能保证可见性
setExclusiveOwnerThread(null);
setState(0);
return true;
}

@Override // 是否持有独占锁
protected boolean isHeldExclusively() {
return getState() == 1;
}

public Condition newCondition() {
return new ConditionObject();
}
}

private MySync sync = new MySync();

@Override // 加锁(不成功会进入等待队列)
public void lock() {
sync.acquire(1);
}

@Override // 加锁,可打断
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}

@Override // 尝试加锁(一次)
public boolean tryLock() {
return sync.tryAcquire(1);
}

@Override // 尝试加锁,带超时
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(time));
}

@Override // 解锁
public void unlock() {
sync.release(1);
}

@Override // 创建条件变量
public Condition newCondition() {
return sync.newCondition();
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class TestAqs {
public static void main(String[] args) {
MyLock lock = new MyLock();
new Thread(() -> {
lock.lock();
// lock.lock();
try {
log.debug("locking...");
sleep(1);
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t1").start();

new Thread(() -> {
lock.lock();
try {
log.debug("locking...");
} finally {
log.debug("unlocking...");
lock.unlock();
}
},"t2").start();
}
}

5、Lock接口

类结构总览:

image

1、什么是Lock

Lock为接口类型,Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。此实现允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的Condition对象。

Lock 锁实现提供了比使用同步方法和语句可以获得的更广泛的锁操作。它们允许更灵活的结构,可能具有非常不同的属性,并且可能支持多个关联的条件对象。Lock 提供了比 synchronized 更多的功能。

2、Lock接口(源码)

1
2
3
4
5
6
7
8
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}

下面来逐个讲述 Lock 接口中每个方法的使用。

1、lock()方法 与 unlock()方法
  • lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。
    • lock锁是不会被打断interrupt的,要想被interrupt打断的话,就得使用lock.lockInterruptibly()进行上锁
  • unlock()方法也是平常使用得最多的一个方法,就是用来释放锁。一般与lock()方法搭配使用。

采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用 Lock 必须在 try{}catch{}finally{}块中进行,并且将释放锁的操作放在finally 块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用 Lock来进行同步的话,是以下面这种形式去使用的:

1
2
3
4
5
6
7
8
9
Lock lock = ...;// 具体实现类
lock.lock(); // 上锁
try {
// 处理任务
} catch(Exception e) {
// 处理异常
} finally {
lock.unlock(); // 释放锁
}

如果要预防可能发生的死锁,可以尝试使用下面这个方法:tryLock(long time, TimeUnit unit)方法

2、tryLock()方法 与 tryLock(long time, TimeUnit unit)方法

tryLock()方法:尝试获取锁,返回一个boolean值

tryLock(long time, TimeUnit unit)方法:尝试获取锁,可以设置超时

这是一个比单纯lock()更具有工程价值的方法,如果大家阅读过JDK的一些内部代码,就不难发现,tryLock()在JDK内部被大量的使用。

Lock可以通过这两个方法拿到当前线程的锁的状态,并且可以数组超时时间。并根据当前线程是否获得锁,做不同的选择:如果成功获取锁,….,如果获取失败,…..

与lock()相比,tryLock()至少有下面一个好处:

  • 可以不用进行无限等待。直接打破形成死锁的条件。如果一段时间等不到锁,可以直接放弃,同时释放自己已经得到的资源。这样,就可以在很大程度上,避免死锁的产生。因为线程之间出现了一种谦让机制。(这也是解决死锁问题的一种方案)
  • 可以在应用程序这层进行进行自旋,你可以自己决定尝试几次,或者是放弃。
  • 等待锁的过程中可以响应中断interrupt,如果此时,程序正好收到关机信号,中断就会触发,进入中断异常后,线程就可以做一些清理工作,从而防止在终止程序时出现数据写坏,数据丢失等悲催的情况。
  • 对于tryLock(空参)来说特别适合在应用层自己对锁进行管理,在应用层进行自旋等待。
3、newCondition()方法

lock可以通过newCondition()方法获得一个Condition对象。

关键字 synchronized 与 wait()/notify()这两个方法一起使用可以实现等待/通知模式, Lock 锁通过 newContition()方法返回 Condition 对象,Condition 类也可以实现等待/通知模式。

用 notify()通知时,JVM 会随机唤醒某个等待的线程, 使用 Condition 类可以进行选择性通知, Condition 比较常用的三个方法:

  • await():会使当前线程等待,同时会释放锁,当其他线程调用 signal()时,线程会重新获得锁并继续执行。
  • signal():用于唤醒一个等待的线程。
  • signaAll():用于唤醒所有等待的线程。

==注意:在调用 Condition 的 await()/signal()方法前,也需要线程持有相关的 Lock 锁,调用 await()后线程会释放这个锁,在 singal()调用后会从当前Condition 对象的等待队列中,唤醒 一个线程,唤醒的线程尝试获得锁, 一旦获得锁成功就继续执行。==

3、Lock接口的实现类——ReentrantLock(重点)

ReentrantLock为常用类,它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。

ReentrantLock 是唯一实现了 Lock 接口的类,并且 ReentrantLock 提供了更多的方法。

相对于 synchronized 它具备如下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

与 synchronized 一样,都支持可重入

1、BAT大厂的面试问题
  • 什么是可重入,什么是可重入锁?它用来解决什么问题?
  • ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗?说说其类内部结构关系。
  • ReentrantLock是如何实现公平锁的?
  • ReentrantLock是如何实现非公平锁的?
  • ReentrantLock默认实现的是公平还是非公平锁?
  • 使用ReentrantLock实现公平和非公平锁的示例?
  • ReentrantLock和Synchronized的对比?
2、ReentrantLock源码分析
1、类的继承关系

ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个Condition条件。

1
public class ReentrantLock implements Lock, java.io.Serializable
2、类的内部类

ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:

image

说明:ReentrantLock类内部总共存在SyncNonfairSyncFairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

  • Sync类

    • 源码

      • abstract static class Sync extends AbstractQueuedSynchronizer {
            // 序列号
            private static final long serialVersionUID = -5179523762034025860L;
        
            // 获取锁
            abstract void lock();
        
            // 非公平方式获取
            final boolean nonfairTryAcquire(int acquires) {
                // 当前线程
                final Thread current = Thread.currentThread();
                // 获取状态
                int c = getState();
                if (c == 0) { // 表示没有线程正在竞争该锁
                    if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用,这里体现了非公平性: 不去检查 AQS 队列
                        // 设置当前线程独占
                        setExclusiveOwnerThread(current); 
                        return true; // 成功
                    }
                }
                // 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入
                else if (current == getExclusiveOwnerThread()) { // 当前线程拥有该锁
                    int nextc = c + acquires; // 增加重入次数
                    if (nextc < 0) // overflow
                        throw new Error("Maximum lock count exceeded");
                    // 设置状态
                    setState(nextc); 
                    // 成功
                    return true; 
                }
                // 失败,回到调用处
                return false;
            }
        
            // 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它
            protected final boolean tryRelease(int releases) {
                // state--
                int c = getState() - releases;
                if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程
                    throw new IllegalMonitorStateException(); // 抛出异常
                // 释放标识
                boolean free = false; 
                // 支持锁重入, 只有 state 减为 0, 才释放成功
                if (c == 0) {
                    free = true;
                    // 已经释放,清空独占
                    setExclusiveOwnerThread(null); 
                }
                // 设置标识
                setState(c); 
                return free; 
            }
        
            // 判断资源是否被当前线程占有
            protected final boolean isHeldExclusively() {
                // While we must in general read state before owner,
                // we don't need to do so to check if current thread is owner
                return getExclusiveOwnerThread() == Thread.currentThread();
            }
        
            // 新生一个条件
            final ConditionObject newCondition() {
                return new ConditionObject();
            }
        
            // Methods relayed from outer class
            // 返回资源的占用线程
            final Thread getOwner() {        
                return getState() == 0 ? null : getExclusiveOwnerThread();
            }
            // 返回状态
            final int getHoldCount() {            
                return isHeldExclusively() ? getState() : 0;
            }
        
            // 资源是否被占用
            final boolean isLocked() {        
                return getState() != 0;
            }
        
            /**
                * Reconstitutes the instance from a stream (that is, deserializes it).
                */
            // 自定义反序列化逻辑
            private void readObject(java.io.ObjectInputStream s)
                throws java.io.IOException, ClassNotFoundException {
                s.defaultReadObject();
                setState(0); // reset to unlocked state
            }
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31

        - Sync类存在如下方法和作用如下:

        - ![image](JUC/java-thread-x-juc-reentrantlock-2.png)

        - NonfairSync类

        - NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法,源码如下:

        - ```java
        // 非公平锁
        static final class NonfairSync extends Sync {
        // 版本号
        private static final long serialVersionUID = 7316153563782823691L;

        // 获得锁
        final void lock() {
        // 首先用 cas 尝试(仅尝试一次)将 state 从 0 改为 1, 如果成功表示获得了独占锁
        if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
        // 把当前线程设置独占了锁
        setExclusiveOwnerThread(Thread.currentThread());
        else // 锁已经被占用,或者set失败
        // 以独占模式获取对象,忽略中断
        // 如果尝试失败,进入 AQS的acquire方法
        acquire(1);
        }

        protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
        }
        }
      • 说明:从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。

    • FairSyn类

      • FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法,源码如下:

        • // 公平锁
          static final class FairSync extends Sync {
              // 版本序列化
              private static final long serialVersionUID = -3000897897090466540L;
          
              final void lock() {
                  // 以独占模式获取对象,忽略中断
                  acquire(1);
              }
          
              /**
                  * Fair version of tryAcquire.  Don't grant access unless
                  * recursive call or no waiters or is first.
                  */
              // 尝试公平获取锁
              protected final boolean tryAcquire(int acquires) {
                  // 获取当前线程
                  final Thread current = Thread.currentThread();
                  // 获取状态
                  int c = getState();
                  if (c == 0) { // 状态为0
                      if (!hasQueuedPredecessors() &&
                          compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
                          // 设置当前线程独占
                          setExclusiveOwnerThread(current);
                          return true;
                      }
                  }
                  else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
                      // 下一个状态
                      int nextc = c + acquires;
                      if (nextc < 0) // 超过了int的表示范围
                          throw new Error("Maximum lock count exceeded");
                      // 设置状态
                      setState(nextc);
                      return true;
                  }
                  return false;
              }
          }
          
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          14
          15
          16
          17
          18
          19

          - 说明:

          - 跟踪lock方法的源码可知,当资源空闲时,它总是会先判断sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。
          - 其中,FairSync类的lock的方法调用如下,只给出了主要的方法。
          - ![image](JUC/java-thread-x-juc-reentrantlock-3.png)
          - 说明:可以看出只要资源被其他线程占用,该线程就会添加到sync queue中的尾部,而不会先尝试获取资源。这也是和Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。

          ###### 3、类的属性

          ReentrantLock类的sync非常重要,对ReentrantLock类的操作大部分都直接转化为对Sync和AbstractQueuedSynchronizer类的操作。

          ```java
          public class ReentrantLock implements Lock, java.io.Serializable {
          // 序列号
          private static final long serialVersionUID = 7373984872572414699L;
          // 同步队列
          private final Sync sync;
          }
4、类的构造函数(默认是采用的非公平策略获取锁)
  • ReentrantLock()型构造函数(默认是采用的非公平策略获取锁)

    • public ReentrantLock() {
          // 默认非公平策略
          sync = new NonfairSync();
      }
      
      1
      2
      3
      4
      5
      6
      7

      - ReentrantLock(boolean)型构造函数(可以传递参数确定采用公平策略或者是非公平策略,参数为true表示公平策略,否则,采用非公平策略)

      - ```java
      public ReentrantLock(boolean fair) {
      sync = fair ? new FairSync() : new NonfairSync();
      }
5、核心函数分析——加锁与解锁

加锁与解锁:(默认为非公平锁实现)(配合上面的源码进行分析)

  • 没有竞争时

    • image-20210812043816368
  • 第一个竞争出现时

    • image-20210812043900546

    • Thread-1 执行了

      1. CAS 尝试将 state 由 0 改为 1,结果失败
      2. 进入 tryAcquire 逻辑,这时 state 已经是1,结果仍然失败
      3. 接下来进入 addWaiter 逻辑,构造 Node 队列
        • 图中黄色三角表示该 Node 的 waitStatus 状态,其中 0 为默认正常状态
        • Node 的创建是懒惰的
        • 其中第一个 Node 称为 Dummy(哑元)或哨兵,用来占位,并不关联线程
        • image-20210812044213292
    • 当前线程进入 acquireQueued 逻辑

      1. acquireQueued 会在一个死循环中不断尝试获得锁,失败后进入 park 阻塞

      2. 如果自己是紧邻着 head(排第二位),那么再次 tryAcquire 尝试获取锁,当然这时 state 仍为 1,失败

      3. 进入 shouldParkAfterFailedAcquire 逻辑,将前驱 node,即 head 的 waitStatus 改为 -1,这次返回 false.(waitStatue为-1表示该结点有责任唤醒它的后继结点)

        image-20210812044725103

      4. shouldParkAfterFailedAcquire 执行完毕回到 acquireQueued ,再次 tryAcquire 尝试获取锁,当然这时state 仍为 1,失败

      5. 当再次进入 shouldParkAfterFailedAcquire 时,这时因为其前驱 node 的 waitStatus 已经是 -1,这次返回true

      6. 进入 parkAndCheckInterrupt, Thread-1 park(灰色表示)

        image-20210812045121967

  • 再次有多个线程经历上述过程竞争失败,变成这个样子

    image-20210812045347108

  • Thread-0 释放锁,进入 tryRelease 流程,如果成功

    • 设置 exclusiveOwnerThread 为 null
    • state = 0

    image-20210812045538702

  • 当前队列不为 null,并且 head 的 waitStatus = -1,进入 unparkSuccessor 流程

  • 找到队列中离 head 最近的一个 Node(没取消的),unpark 恢复其运行,本例中即为 Thread-1

  • 回到 Thread-1 的 acquireQueued 流程

    image-20210812045816095

  • 如果加锁成功(没有竞争),会设置

    • exclusiveOwnerThread 为 Thread-1,state = 1
    • head 指向刚刚 Thread-1 所在的 Node,该 Node 清空 Thread
    • 原本的 head 因为从链表断开,而可被垃圾回收
  • 如果这时候有其它线程来竞争(非公平的体现),例如这时有 Thread-4 来了

    image-20210812050213728

  • 如果不巧又被 Thread-4 占了先

    • Thread-4 被设置为 exclusiveOwnerThread,state = 1
    • Thread-1 再次进入 acquireQueued 流程,获取锁失败,重新进入 park 阻塞
6、核心函数分析——可重入原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static final class NonfairSync extends Sync {
// ...
// Sync 继承过来的方法, 方便阅读, 放在此处
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 如果已经获得了锁, 线程还是当前线程, 表示发生了锁重入
else if (current == getExclusiveOwnerThread()) {
// state++
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded"); setState(nextc);
return true;
}
return false;
}

// Sync 继承过来的方法, 方便阅读, 放在此处
protected final boolean tryRelease(int releases) {
// state--
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 支持锁重入, 只有 state 减为 0, 才释放成功
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
}
7、核心函数分析——可打断原理

不可打断模式

在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Sync 继承自 AQS
static final class NonfairSync extends Sync {
// ...
private final boolean parkAndCheckInterrupt() {
// 如果打断标记已经是 true, 则 park 会失效
LockSupport.park(this);
// interrupted 会清除打断标记
return Thread.interrupted();
}

final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
// 还是需要获得锁后, 才能返回打断状态
return interrupted;
}
if(shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()
) {
// 如果是因为 interrupt 被唤醒, 返回打断状态为 true
interrupted = true;
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}

public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
){
// 如果打断状态为 true
selfInterrupt();
}
}

static void selfInterrupt() {
// 重新产生一次中断
Thread.currentThread().interrupt();
}
}

可打断模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static final class NonfairSync extends Sync {
public final void acquireInterruptibly(int arg) throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException(); // 如果没有获得到锁, 进入 ㈠
if (!tryAcquire(arg))
doAcquireInterruptibly(arg);
}

// ㈠ 可打断的获取锁流程
private void doAcquireInterruptibly(int arg) throws InterruptedException {
final Node node = addWaiter(Node.EXCLUSIVE);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) {
// 在 park 过程中如果被 interrupt 会进入此
// 这时候抛出异常, 而不会再次进入 for (;;)
throw new InterruptedException();
}
}
} finally {
if (failed)
cancelAcquire(node);
}
}
}

通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。

所以可知,在ReentrantLock的背后,是AQS对其服务提供了支持,下面还是通过例子来更进一步分析源码。

8、核心函数分析——条件变量实现原理

每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject

await 流程

  • 开始 Thread-0 持有锁,调用 await,进入 ConditionObject 的 addConditionWaiter 流程

  • 创建新的 Node 状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列尾部

    image-20210812233522165

  • 接下来进入 AQS 的 fullyRelease 流程,释放同步器上的锁(为什么调用fullyRelease而不是调用realease:因为该线程可能有重入锁,调用fullyRelease可以将该线程所占的所有锁全部释放掉)

    image-20210812233738920

  • unpark AQS 队列中的下一个节点,竞争锁,假设没有其他竞争线程,那么 Thread-1 竞争成功

    image-20210812234025126

  • park 阻塞 Thread-0

    image-20210812234152831

这里其实是thread0 unpark thread1,但此时1并没有竞争到锁因为0还持有锁,等到thread0 park自己时,1才竞争到锁,因为unpark和park可以互换顺序

signal 流程

  • 假设 Thread-1 要来唤醒 Thread-0

    image-20210812234448884

  • 进入 ConditionObject 的 doSignal 流程,取得等待队列中第一个 Node,即 Thread-0 所在 Node

    image-20210812234608451

  • 执行 transferForSignal 流程,将该 Node 加入 AQS 队列尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的waitStatus 改为 -1

    image-20210812234954747

  • Thread-1 释放锁,进入 unlock 流程,略

3、示例分析
公平锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}

public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}

public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock(true);

MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
MyThread t3 = new MyThread("t3", lock);
t1.start();
t2.start();
t3.start();
}
}

运行结果(某一次):

1
2
3
Thread[t1,5,main] running
Thread[t2,5,main] running
Thread[t3,5,main] running

说明:该示例使用的是公平策略,由结果可知,可能会存在如下一种时序。

image

说明:首先,t1线程的lock操作 -> t2线程的lock操作 -> t3线程的lock操作 -> t1线程的unlock操作 -> t2线程的unlock操作 -> t3线程的unlock操作。根据这个时序图来进一步分析源码的工作流程:

  • t1线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由调用流程可知,t1线程成功获取了资源,可以继续执行。
  • t2线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最后的结果是t2线程会被禁止,因为调用了LockSupport.park。
  • t3线程执行lock.lock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最后的结果是t3线程会被禁止,因为调用了LockSupport.park。
  • t1线程调用了lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:如上图所示,最后,head的状态会变为0,t2线程会被unpark,即t2线程可以继续运行。此时t3线程还是被禁止。
  • t2获得cpu资源,继续运行,由于t2之前被park了,现在需要恢复之前的状态,下图给出了方法调用中的主要方法:
    • image
    • 说明:在setHead函数中会将head设置为之前head的下一个结点,并且将pre域与thread域都设置为null,在acquireQueued返回之前,sync queue就只有两个结点了。
  • t2执行lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:由上图可知,最终unpark t3线程,让t3线程可以继续运行。
  • t3线程获取cpu资源,恢复之前的状态,继续运行。
    • image
    • 说明:最终达到的状态是sync queue中只剩下了一个结点,并且该节点除了状态为0外,其余均为null。
  • t3执行lock.unlock,下图给出了方法调用中的主要方法:
    • image
    • 说明:最后的状态和之前的状态是一样的,队列中有一个空节点,头结点为尾节点均指向它。

6、ReadWriteLock接口

ReadWriteLock为接口类型, 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。

在ReadWriteLock接口里面只定义了两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading.
*/
Lock readLock();

/**
* Returns the lock used for writing.
*
* @return the lock used for writing.
*/
Lock writeLock();
}

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成 2 个锁来分配给线程,从而使得多个线程可以同时进行读操作。下面的ReentrantReadWriteLock 实现了 ReadWriteLock 接口。

1、ReadWriteLock接口实现类——ReentrantReadWriteLock读写锁(重要)

现实中有这样一种场景:对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源, 就不应该允许其他线程对该资源进行读和写的操作了。类似于数据库中的select ...from ... lock in share mode

针对这种场景,JAVA 的并发包JUC提供了读写锁 ReentrantReadWriteLock, ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。

ReentrantReadWriteLock 里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()和 writeLock()用来获取读锁和写锁。

  1. 线程进入读锁的前提条件:
    • 没有其他线程的写锁
    • 没有写请求,或者==有写请求,但调用线程和持有锁的线程是同一个(可重入锁)==。
  2. 线程进入写锁的前提条件:
    • 没有其他线程的读锁
    • 没有其他线程的写锁

而读写锁有以下三个重要的特性:

  1. ==公平选择性==:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
  2. ==重进入==:读锁和写锁都支持线程重进入。
  3. ==锁降级==:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁
1、BAT大厂的面试问题
  • 为了有了ReentrantLock还需要ReentrantReadWriteLock?
  • ReentrantReadWriteLock底层实现原理?
  • ReentrantReadWriteLock底层读写状态如何设计的?
    • 高16位为读锁,低16位为写锁
  • 读锁和写锁的最大数量是多少?
  • 本地线程计数器ThreadLocalHoldCounter是用来做什么的?
  • 缓存计数器HoldCounter是用来做什么的?
  • 写锁的获取与释放是怎么实现的?
  • 读锁的获取与释放是怎么实现的?
  • RentrantReadWriteLock为什么不支持锁升级?
  • 什么是锁的升降级?RentrantReadWriteLock为什么不支持锁升级?
2、ReentrantReadWriteLock数据结构

ReentrantReadWriteLock底层是基于ReentrantLockAbstractQueuedSynchronizer来实现的,所以,ReentrantReadWriteLock的数据结构也依托于AQS的数据结构。

3、ReentrantReadWriteLock源码分析
1、类的继承关系
1
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {}

说明:可以看到,ReentrantReadWriteLock实现了ReadWriteLock接口,ReadWriteLock接口定义了获取读锁和写锁的规范,具体需要实现类去实现;同时其还实现了Serializable接口,表示可以进行序列化,在源代码中可以看到ReentrantReadWriteLock实现了自己的序列化逻辑。

2、类的内部类

ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示:

img

说明:如上图所示,Sync继承自AQS、NonfairSync继承自Sync类、FairSync继承自Sync类;ReadLock实现了Lock接口、WriteLock也实现了Lock接口。

3、内部类——Sync类
  • 类的继承关系

    • abstract static class Sync extends AbstractQueuedSynchronizer {}
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - 说明:Sync抽象类继承自AQS抽象类,Sync类提供了对ReentrantReadWriteLock的支持。

      - 类的内部类

      - Sync类内部存在两个内部类,分别为HoldCounter和ThreadLocalHoldCounter,其中HoldCounter主要与读锁配套使用,其中,HoldCounter源码如下:

      - ```java
      // 计数器
      static final class HoldCounter {
      // 计数
      int count = 0;
      // Use id, not reference, to avoid garbage retention
      // 获取当前线程的TID属性的值
      final long tid = getThreadId(Thread.currentThread());
      }
    • 说明:HoldCounter主要有两个属性,count和tid,其中count表示某个读线程重入的次数tid表示该线程的tid字段的值,该字段可以用来唯一标识一个线程

    • ThreadLocalHoldCounter的源码如下:

      • // 本地线程计数器
        static final class ThreadLocalHoldCounter
            extends ThreadLocal<HoldCounter> {
            // 重写初始化方法,在没有进行set的情况下,获取的都是该HoldCounter值
            public HoldCounter initialValue() {
                return new HoldCounter();
            }
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26

        - 说明:ThreadLocalHoldCounter重写了ThreadLocal的initialValue方法,ThreadLocal类可以将线程与对象相关联。在没有进行set的情况下,get到的均是initialValue方法里面生成的那个HolderCounter对象。

        - 类的属性

        - ```java
        abstract static class Sync extends AbstractQueuedSynchronizer {
        // 版本序列号
        private static final long serialVersionUID = 6317671515068378041L;
        // 高16位为读锁,低16位为写锁
        static final int SHARED_SHIFT = 16;
        // 读锁单位
        static final int SHARED_UNIT = (1 << SHARED_SHIFT);
        // 读锁最大数量
        static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
        // 写锁最大数量
        static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
        // 本地线程计数器
        private transient ThreadLocalHoldCounter readHolds;
        // 缓存的计数器
        private transient HoldCounter cachedHoldCounter;
        // 第一个读线程
        private transient Thread firstReader = null;
        // 第一个读线程的计数
        private transient int firstReaderHoldCount;
        }
    • 说明:该属性中包括了读锁写锁线程的最大量本地线程计数器等。

  • 类的构造函数

    • // 构造函数
      Sync() {
          // 本地线程计数器
          readHolds = new ThreadLocalHoldCounter();
          // 设置AQS的状态
          setState(getState()); // ensures visibility of readHolds
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 说明:在Sync的构造函数中**设置了本地线程计数器和AQS的状态state**。

      ###### 4、内部类——Sync核心函数分析

      对ReentrantReadWriteLock对象的操作绝大多数都转发至Sync对象进行处理。下面对Sync类中的重点函数进行分析:

      - sharedCount函数

      - 表示**占有读锁的线程数量**,源码如下:

      - ```java
      static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
    • 说明:直接将state右移16位,就可以得到读锁的线程数量,因为state的高16位表示读锁,对应的低十六位表示写锁数量

  • exclusiveCount函数

    • 表示占有写锁的线程数量,源码如下:

    • static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26

      - 说明:直接将状态state和(2^16 - 1)做与运算,其等效于将state模上2^16。**写锁数量由state的低十六位表示**。

      - tryRelease函数

      - ```java
      /*
      * Note that tryRelease and tryAcquire can be called by
      * Conditions. So it is possible that their arguments contain
      * both read and write holds that are all released during a
      * condition wait and re-established in tryAcquire.
      */

      protected final boolean tryRelease(int releases) {
      // 判断是否伪独占线程
      if (!isHeldExclusively())
      throw new IllegalMonitorStateException();
      // 计算释放资源后的写锁的数量
      int nextc = getState() - releases;
      // 因为可重入的原因, 写锁计数为 0, 才算释放成功
      boolean free = exclusiveCount(nextc) == 0; // 是否释放成功
      if (free)
      setExclusiveOwnerThread(null); // 设置独占线程为空
      setState(nextc); // 设置状态
      return free;
      }
    • 说明:此函数用于释放写锁资源:首先会判断该线程是否为独占线程,若不为独占线程,则抛出异常,否则,计算释放资源后的写锁的数量,若为0,表示成功释放,资源不将被占用,否则,表示资源还被占用。其函数流程图如下:

    • img

  • tryAcquire函数

    • protected final boolean tryAcquire(int acquires) {
          /*
              * Walkthrough:
              * 1. If read count nonzero or write count nonzero
              *    and owner is a different thread, fail.
              * 2. If count would saturate, fail. (This can only
              *    happen if count is already nonzero.)
              * 3. Otherwise, this thread is eligible for lock if
              *    it is either a reentrant acquire or
              *    queue policy allows it. If so, update state
              *    and set owner.
              */
          // 获取当前线程
          Thread current = Thread.currentThread();
          // 获取状态
          // 获得低 16 位, 代表写锁的 state 计数
          int c = getState();
          // 写线程数量
          int w = exclusiveCount(c);
          if (c != 0) { // 状态不为0
              // (Note: if c != 0 and w == 0 then shared count != 0)
              // 写线程数量为0或者当前线程没有占有独占资源
              if (
                  // c != 0 and w == 0 表示有读锁, 或者
                  w == 0 || 
                  // 如果 exclusiveOwnerThread 不是自己(可重入)
                  current != getExclusiveOwnerThread()) 
                  // 获得锁失败
                  return false;
              // 写锁计数超过低 16 位, 报异常
              if (w + exclusiveCount(acquires) > MAX_COUNT) // 判断是否超过最高写线程数量
                  throw new Error("Maximum lock count exceeded");
              // Reentrant acquire
              // 设置AQS状态
              // 写锁重入, 获得锁成功
              setState(c + acquires);
              return true;
          }
          if (
              // 判断写锁是否该阻塞, 或者
              writerShouldBlock() ||
              // 尝试更改计数失败
              !compareAndSetState(c, c + acquires))
              // 获得锁失败
              return false;
          // 设置独占线程
          // 获得锁成功
          setExclusiveOwnerThread(current);
          return true;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
          
      - 说明:此函数用于**获取写锁**:首先会获取state,判断是否为0,若为0,表示此时没有读锁线程,再判断写线程是否应该被阻塞,而**在非公平策略下总是不会被阻塞,在公平策略下会进行判断(判断同步队列中是否有等待时间更长的线程,若存在,则需要被阻塞,否则,无需阻塞**),之后在设置状态state,然后返回true。若state不为0,则表示此时存在读锁或写锁线程,若写锁线程数量为0或者当前线程为独占锁线程,则返回false,表示不成功,否则,判断写锁线程的重入次数是否大于了最大值,若是,则抛出异常,否则,设置状态state,返回true,表示成功。其函数流程图如下:

      - ![img](JUC/java-thread-x-readwritelock-3.png)

      - tryReleaseShared函数

      - ```java
      protected final boolean tryReleaseShared(int unused) {
      // 获取当前线程
      Thread current = Thread.currentThread();
      if (firstReader == current) { // 当前线程为第一个读线程
      // assert firstReaderHoldCount > 0;
      if (firstReaderHoldCount == 1) // 读线程占用的资源数为1
      firstReader = null;
      else // 减少占用的资源
      firstReaderHoldCount--;
      } else { // 当前线程不为第一个读线程
      // 获取缓存的计数器
      HoldCounter rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
      // 获取当前线程对应的计数器
      rh = readHolds.get();
      // 获取计数
      int count = rh.count;
      if (count <= 1) { // 计数小于等于1
      // 移除
      readHolds.remove();
      if (count <= 0) // 计数小于等于0,抛出异常
      throw unmatchedUnlockException();
      }
      // 减少计数
      --rh.count;
      }
      for (;;) { // 无限循环
      // 获取状态
      int c = getState();
      // 获取状态
      int nextc = c - SHARED_UNIT;
      if (compareAndSetState(c, nextc)) // 比较并进行设置
      // Releasing the read lock has no effect on readers,
      // but it may allow waiting writers to proceed if
      // both read and write locks are now free.
      // 读锁的计数不会影响其它获取读锁线程, 但会影响其它获取写锁线程
      // 计数为 0 才是真正释放
      return nextc == 0;
      }
      }
    • 说明:此函数表示读锁线程释放锁:首先判断当前线程是否为第一个读线程firstReader,若是,则判断第一个读线程占有的资源数firstReaderHoldCount是否为1,若是,则设置第一个读线程firstReader为空,否则,将第一个读线程占有的资源数firstReaderHoldCount减1;若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者tid不等于当前线程的tid值,则获取当前线程的计数器,如果计数器的计数count小于等于1,则移除当前线程对应的计数器,如果计数器的计数count小于等于0,则抛出异常,之后再减少计数即可。无论何种情况,都会进入无限循环,该循环可以确保成功设置状态state。其流程图如下:

    • img

  • tryAcquireShared函数

    • private IllegalMonitorStateException unmatchedUnlockException() {
          return new IllegalMonitorStateException(
              "attempt to unlock read lock, not locked by current thread");
      }
      
      // 共享模式下获取资源
      protected final int tryAcquireShared(int unused) {
          /*
              * Walkthrough:
              * 1. If write lock held by another thread, fail.
              * 2. Otherwise, this thread is eligible for
              *    lock wrt state, so ask if it should block
              *    because of queue policy. If not, try
              *    to grant by CASing state and updating count.
              *    Note that step does not check for reentrant
              *    acquires, which is postponed to full version
              *    to avoid having to check hold count in
              *    the more typical non-reentrant case.
              * 3. If step 2 fails either because thread
              *    apparently not eligible or CAS fails or count
              *    saturated, chain to version with full retry loop.
              */
          // 获取当前线程
          Thread current = Thread.currentThread();
          // 获取状态
          int c = getState();
          // 如果是其它线程持有写锁, 获取读锁失败
          if (exclusiveCount(c) != 0 &&
              getExclusiveOwnerThread() != current) // 写线程数不为0并且占有资源的不是当前线程
              return -1;
          // 读锁数量
          int r = sharedCount(c);
          if (// 读线程是否应该被阻塞、并且小于最大值、并且比较设置成功
              // 读锁不该阻塞(如果老二是写锁,读锁该阻塞), 并且
              !readerShouldBlock() &&
              // 小于读锁计数, 并且
              r < MAX_COUNT &&
              // 尝试增加计数成功
              compareAndSetState(c, c + SHARED_UNIT)) { 
              if (r == 0) { // 读锁数量为0
                  // 设置第一个读线程
                  firstReader = current;
                  // 读线程占用的资源数为1
                  firstReaderHoldCount = 1;
              } else if (firstReader == current) { // 当前线程为第一个读线程
                  // 占用资源数加1
                  firstReaderHoldCount++;
              } else { // 读锁数量不为0并且不为当前线程
                  // 获取计数器
                  HoldCounter rh = cachedHoldCounter;
                  if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
                      // 获取当前线程对应的计数器
                      cachedHoldCounter = rh = readHolds.get();
                  else if (rh.count == 0) // 计数为0
                      // 设置
                      readHolds.set(rh);
                  rh.count++;
              }
              return 1;
          }
          return fullTryAcquireShared(current);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
          
      - 说明:此函数表示**读锁线程获取读锁**。首先判断写锁是否为0并且当前线程不占有独占锁,直接返回;否则,判断读线程是否需要被阻塞并且读锁数量是否小于最大值并且比较设置状态成功,若当前没有读锁,则设置第一个读线程firstReader和firstReaderHoldCount;若当前线程线程为第一个读线程,则增加firstReaderHoldCount;否则,将设置当前线程对应的HoldCounter对象的值。流程图如下:

      - ![img](JUC/java-thread-x-readwritelock-5.png)

      - fullTryAcquireShared函数

      - ```java
      // 非公平锁 readerShouldBlock 看 AQS 队列中第一个节点是否是写锁
      // true 则该阻塞, false 则不阻塞
      final boolean readerShouldBlock() {
      return apparentlyFirstQueuedIsExclusive();
      }

      // 与 tryAcquireShared 功能类似, 但会不断尝试 for (;;) 获取读锁, 执行过程中无阻塞
      final int fullTryAcquireShared(Thread current) {
      /*
      * This code is in part redundant with that in
      * tryAcquireShared but is simpler overall by not
      * complicating tryAcquireShared with interactions between
      * retries and lazily reading hold counts.
      */
      HoldCounter rh = null;
      for (;;) { // 无限循环
      // 获取状态
      int c = getState();
      if (exclusiveCount(c) != 0) { // 写线程数量不为0
      if (getExclusiveOwnerThread() != current) // 不为当前线程
      return -1;
      // else we hold the exclusive lock; blocking here
      // would cause deadlock.
      } else if (readerShouldBlock()) { // 写线程数量为0并且读线程被阻塞
      // Make sure we're not acquiring read lock reentrantly
      if (firstReader == current) { // 当前线程为第一个读线程
      // assert firstReaderHoldCount > 0;
      } else { // 当前线程不为第一个读线程
      if (rh == null) { // 计数器不为空
      //
      rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current)) { // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
      rh = readHolds.get();
      if (rh.count == 0)
      readHolds.remove();
      }
      }
      if (rh.count == 0)
      return -1;
      }
      }
      if (sharedCount(c) == MAX_COUNT) // 读锁数量为最大值,抛出异常
      throw new Error("Maximum lock count exceeded");
      if (compareAndSetState(c, c + SHARED_UNIT)) { // 比较并且设置成功
      if (sharedCount(c) == 0) { // 读线程数量为0
      // 设置第一个读线程
      firstReader = current;
      //
      firstReaderHoldCount = 1;
      } else if (firstReader == current) {
      firstReaderHoldCount++;
      } else {
      if (rh == null)
      rh = cachedHoldCounter;
      if (rh == null || rh.tid != getThreadId(current))
      rh = readHolds.get();
      else if (rh.count == 0)
      readHolds.set(rh);
      rh.count++;
      cachedHoldCounter = rh; // cache for release
      }
      return 1;
      }
      }
      }
    • 说明:在tryAcquireShared函数中,如果下列三个条件不满足:

      • 读线程是否应该被阻塞
      • 小于最大值
      • 比较设置成功
    • 则会进行fullTryAcquireShared函数中,它用来保证相关操作可以成功。其逻辑与tryAcquireShared逻辑类似,不再累赘。

  • doAcquireShared()函数

    • private void doAcquireShared(int arg) {
          // 将当前线程关联到一个 Node 对象上, 模式为共享模式
          final Node node = addWaiter(Node.SHARED);
          boolean failed = true;
          try {
              boolean interrupted = false;
              for (;;) {
                  final Node p = node.predecessor();
                  if (p == head) {
                      // 再一次尝试获取读锁
                      int r = tryAcquireShared(arg); // 成功
                      if (r >= 0) {
                          // ㈠
                          // r 表示可用资源数, 在这里总是 1 允许传播
                          //(唤醒 AQS 中下一个 Share 节点) 
                          setHeadAndPropagate(node, r);
                          p.next = null; // help GC
                          if (interrupted)
                              selfInterrupt();
                          failed = false;
                          return;
                      }
                  }
                  if (
                      // 是否在获取读锁失败时阻塞(前一个阶段 waitStatus == Node.SIGNAL) 
                      shouldParkAfterFailedAcquire(p, node) &&
                      // park 当前线程
                      parkAndCheckInterrupt()
                  ){
                      interrupted = true;
                  }
              }
          }  finally {
              if (failed)
                  cancelAcquire(node);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34



      而其他内部类的操作基本上都是转化到了对Sync对象的操作,在此不再累赘。

      ###### 5、类的属性

      ```java
      public class ReentrantReadWriteLock
      implements ReadWriteLock, java.io.Serializable {
      // 版本序列号
      private static final long serialVersionUID = -6992448646407690164L;
      // 读锁
      private final ReentrantReadWriteLock.ReadLock readerLock;
      // 写锁
      private final ReentrantReadWriteLock.WriteLock writerLock;
      // 同步队列
      final Sync sync;

      private static final sun.misc.Unsafe UNSAFE;
      // 线程ID的偏移地址
      private static final long TID_OFFSET;
      static {
      try {
      UNSAFE = sun.misc.Unsafe.getUnsafe();
      Class<?> tk = Thread.class;
      // 获取线程的tid字段的内存地址
      TID_OFFSET = UNSAFE.objectFieldOffset
      (tk.getDeclaredField("tid"));
      } catch (Exception e) {
      throw new Error(e);
      }
      }
      }

说明:可以看到ReentrantReadWriteLock属性包括了一个ReentrantReadWriteLock.ReadLock对象,表示读锁;一个ReentrantReadWriteLock.WriteLock对象,表示写锁;一个Sync对象,表示同步队列。

6、类的构造函数
  • ReentrantReadWriteLock()型构造函数

    • public ReentrantReadWriteLock() {
          this(false);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14

      - 说明:此构造函数会调用另外一个有参构造函数。

      - ReentrantReadWriteLock(boolean)型构造函数

      - ```java
      public ReentrantReadWriteLock(boolean fair) {
      // 公平策略或者是非公平策略
      sync = fair ? new FairSync() : new NonfairSync();
      // 读锁
      readerLock = new ReadLock(this);
      // 写锁
      writerLock = new WriteLock(this);
      }
    • 说明:可以指定设置公平策略或者非公平策略,并且该构造函数中生成了读锁与写锁两个对象。如果调用的是空参的构造函数,则默认是非公平的策略

7、核心函数分析

对ReentrantReadWriteLock的操作基本上都转化为了对Sync对象的操作,而Sync的函数已经分析过,不再累赘。

8、图解ReentrantReadWriteLock执行流程

读写锁用的是同一个 Sycn 同步器,因此等待队列、state 等也是同一个。

t1 w.lock,t2 r.lock:t1线程为写锁,t2线程为读锁(默认为非公平锁)

  1. t1 成功上锁,流程与 ReentrantLock 加锁相比没有特殊之处,不同是写锁状态占了 state 的低 16 位,而读锁使用的是 state 的高 16 位

    image-20210813022016104

  2. t2 执行 r.lock,这时进入读锁的 sync.acquireShared(1) 流程,首先会进入 tryAcquireShared 流程。如果有写锁占据,那么 tryAcquireShared 返回 -1 表示失败

    • tryAcquireShared 返回值表示
      • -1 表示失败
      • 0 表示成功,但后继节点不会继续唤醒
      • 正数表示成功,而且数值是还有几个后继节点需要唤醒,读写锁返回 1

    image-20210813023347980

  3. 这时会进入 sync.doAcquireShared(1) 流程,首先也是调用 addWaiter 添加节点,不同之处在于节点被设置为Node.SHARED 模式而非 Node.EXCLUSIVE 模式,注意此时 t2 仍处于活跃状态

    image-20210813023500583

  4. t2 会看看自己的节点是不是老二,如果是,还会再次调用 tryAcquireShared(1) 来尝试获取锁

  5. 如果没有成功,在 doAcquireShared 内 for (;;) 循环一次,把前驱节点的 waitStatus 改为 -1,再 for (;;) 循环一次尝试 tryAcquireShared(1) 如果还不成功,那么在 parkAndCheckInterrupt() 处 park

    image-20210813023653011

  6. t3 r.lock,t4 w.lock:t3线程为读锁,t4线程为写锁。这种状态下,假设又有 t3 加读锁和 t4 加写锁,这期间 t1 仍然持有锁,就变成了下面的样子(由于t3是读锁,为共享锁,所以状态是Shared,而t4是写锁,为独占锁,所以状态是Ex)这里状态的不同是为了之后的解锁做准备,不同状态的解锁方式不同

    image-20210813023151128

  7. t1 w.unlock:t1线程释放了写锁。这时会走到写锁的 sync.release(1) 流程,调用 sync.tryRelease(1) 成功,变成下面的样子

    image-20210813024250098

  8. 接下来执行唤醒流程 sync.unparkSuccessor,即让老二恢复运行,这时 t2 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行

  9. 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一(其中的readerShouldBlock()方法是区别公平锁和非公平锁的关键,非公平锁不阻塞,公平锁就阻塞)(同样的writerShouldBlock()也是区别写锁公平和非公平的关键)

    image-20210813024836077

  10. 这时 t2 已经恢复运行,接下来 t2 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

    image-20210813024953061

  11. 事情还没完,在 setHeadAndPropagate 方法内还会检查下一个节点是否是 shared,如果是则调用doReleaseShared() 将 head 的状态从 -1 改为 0 并唤醒老二,这时 t3 在 doAcquireShared 内parkAndCheckInterrupt() 处恢复运行((setHead(node)之后的代码的意思是,当阻塞队列中有多个连续的读线程时,会传播式地逐一唤醒,if(s.isShared()){doReleaseShared()}这段代码是关键,吧读锁的状态设置成Shared也是为了这里))

    image-20210813025411978

  12. 这回再来一次 for (;;) 执行 tryAcquireShared 成功则让读锁计数加一

    image-20210813025646877

  13. 这时 t3 已经恢复运行,接下来 t3 调用 setHeadAndPropagate(node, 1),它原本所在节点被置为头节点

    image-20210813030410741

  14. 下一个节点不是 shared 了,因此不会继续唤醒 t4 所在节点

  15. t2 r.unlock,t3 r.unlock:t2线程解锁,t3线程解锁:t2 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,但由于计数还不为零

    image-20210813030740325

  16. t3 进入 sync.releaseShared(1) 中,调用 tryReleaseShared(1) 让计数减一,这回计数为零了,进入doReleaseShared() 将头节点从 -1 改为 0 并唤醒老二,即

    image-20210813031008456

  17. 之后 t4 在 acquireQueued 中 parkAndCheckInterrupt 处恢复运行,再次 for (;;) 这次自己是老二,并且没有其他竞争,tryAcquire(1) 成功,修改头结点,流程结束

    image-20210813031102400

4、ReentrantReadWriteLock示例

下面给出了一个使用ReentrantReadWriteLock的示例,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import java.util.concurrent.locks.ReentrantReadWriteLock;

class ReadThread extends Thread {
private ReentrantReadWriteLock rrwLock;

public ReadThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.readLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rrwLock.readLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}

class WriteThread extends Thread {
private ReentrantReadWriteLock rrwLock;

public WriteThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
} finally {
rrwLock.writeLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}

public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
ReentrantReadWriteLock rrwLock = new ReentrantReadWriteLock();
ReadThread rt1 = new ReadThread("rt1", rrwLock);
ReadThread rt2 = new ReadThread("rt2", rrwLock);
WriteThread wt1 = new WriteThread("wt1", rrwLock);
rt1.start();
rt2.start();
wt1.start();
}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
rt1 trying to lock
rt2 trying to lock
wt1 trying to lock
rt1 lock successfully
rt2 lock successfully
rt1 unlock successfully
rt2 unlock successfully
wt1 lock successfully
wt1 unlock successfully

说明:程序中生成了一个ReentrantReadWriteLock对象,并且设置了两个读线程,一个写线程。根据结果,可能存在如下的时序图:

img

  • rt1线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的状态state为2^16 次方,即表示此时读线程数量为1
  • rt2线程执行rrwLock.readLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的状态state为2 * 2^16次方,即表示此时读线程数量为2
  • wt1线程执行rrwLock.writeLock().lock操作,主要的函数调用如下:
    • img
    • 说明:此时,在同步队列Sync queue中存在两个结点,并且wt1线程会被禁止运行。
  • rt1线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的state为2^16次方,表示还有一个读线程。
  • rt2线程执行rrwLock.readLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:当rt2线程执行unlock操作后,AQS的state为0,并且wt1线程将会被unpark,其获得CPU资源就可以运行。
  • wt1线程获得CPU资源,继续运行,需要恢复。由于之前acquireQueued函数中的parkAndCheckInterrupt函数中被禁止的,所以,恢复到parkAndCheckInterrupt函数中,主要的函数调用如下:
    • img
    • 说明:最后,sync queue队列中只有一个结点,并且头结点尾节点均指向它,AQS的state值为1,表示此时有一个写线程。
  • wt1执行rrwLock.writeLock().unlock操作,主要的函数调用如下:
    • img
    • 说明:此时,AQS的state为0,表示没有任何读线程或者写线程了。并且Sync queue结构与上一个状态的结构相同,没有变化。
5、更深入理解
1、什么是锁升降级?

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程

接下来看一个锁降级的示例。因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}

上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问processData()方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。

image-20210726213514196

2、锁降级中读锁的获取是否必要呢?

答案是必要的。主要是为了==保证数据的可见性==,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。

3、RentrantReadWriteLock支不支持锁升级?

RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是**==保证数据可见性==,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。**

6、使用读写锁实现一致性缓存(保证缓存与数据库数据一致)
1、缓存更新策略

更新时,是先清缓存还是先更新数据库?

先清缓存:

image-20210813015618419

先更新数据库:

image-20210813015913860

补充一种情况,假设查询线程 A 查询数据时恰好缓存数据由于时间到期失效,或是第一次查询

image-20210813021033756

这种情况的出现几率非常小,见 facebook 论文

2、使用读写锁实现一个简单的按需加载缓存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
import java.util.*;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class TestGenericDao {
public static void main(String[] args) {
GenericDao dao = new GenericDaoCached();
System.out.println("============> 查询");
String sql = "select * from emp where empno = ?";
int empno = 7369;
Emp emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);

System.out.println("============> 更新");
dao.update("update emp set sal = ? where empno = ?", 800, empno);
emp = dao.queryOne(Emp.class, sql, empno);
System.out.println(emp);
}
}

class GenericDaoCached extends GenericDao {
private GenericDao dao = new GenericDao();
private Map<SqlPair, Object> map = new HashMap<>();
private ReentrantReadWriteLock rw = new ReentrantReadWriteLock();

@Override
public <T> List<T> queryList(Class<T> beanClass, String sql, Object... args) {
return dao.queryList(beanClass, sql, args);
}

@Override
public <T> T queryOne(Class<T> beanClass, String sql, Object... args) {
// 先从缓存中找,找到直接返回
SqlPair key = new SqlPair(sql, args);;
rw.readLock().lock();
try {
T value = (T) map.get(key);
if(value != null) {
return value;
}
} finally {
rw.readLock().unlock();
}
rw.writeLock().lock();
try {
// 多个线程
T value = (T) map.get(key);
if(value == null) {
// 缓存中没有,查询数据库
value = dao.queryOne(beanClass, sql, args);
map.put(key, value);
}
return value;
} finally {
rw.writeLock().unlock();
}
}

@Override
public int update(String sql, Object... args) {
rw.writeLock().lock();
try {
// 先更新库
int update = dao.update(sql, args);
// 清空缓存
map.clear();
return update;
} finally {
rw.writeLock().unlock();
}
}

class SqlPair {
private String sql;
private Object[] args;

public SqlPair(String sql, Object[] args) {
this.sql = sql;
this.args = args;
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
SqlPair sqlPair = (SqlPair) o;
return Objects.equals(sql, sqlPair.sql) &&
Arrays.equals(args, sqlPair.args);
}

@Override
public int hashCode() {
int result = Objects.hash(sql);
result = 31 * result + Arrays.hashCode(args);
return result;
}
}
}

注意:

  • 以上实现体现的是读写锁的应用,保证缓存和数据库的一致性,但有下面的问题没有考虑
    • 适合读多写少,如果写操作比较频繁,以上实现性能低
    • 没有考虑缓存容量
    • 没有考虑缓存过期
    • 只适合单机
    • 并发性还是低,目前只会用一把锁(其实可以把锁再细化,不同的表用不同的锁)
    • 更新方法太过简单粗暴,清空了所有 key(考虑按类型分区或重新设计 key)
  • 乐观锁实现:用 CAS 去更新
7、ReentrantReadWriteLock总结
  • 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
  • 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

原因:当线程获取读锁的时候,可能有其他线程同时也在持有读锁,因此不能把获取读锁的线程“升级”为写锁;而对于获得写锁的线程,它一定独占了读写锁,因此可以继续让它获取读锁,当它同时获取了写锁和读锁后,还可以先释放写锁继续持有读锁,这样一个写锁就“降级”为了读锁。

image-20210726213440799

注意事项:

  • 读锁不支持条件变量

  • 重入时升级不支持:即持有读锁的情况下去获取写锁,会导致获取写锁永久等待

    • r.lock();
      try {
          // ...
          w.lock();
          try {
              // ...
          } finally{
              w.unlock();
          }
      } finally{
          r.unlock();
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16

      - 重入时降级支持:即持有写锁的情况下去获取读锁



      #### 2、对ReentrantReadWriteLock性能再提升——`StampedLock`

      ##### 1、StampedLock概述

      该类自 JDK 8 加入,是为了进一步优化读性能,它的特点是**在使用读锁、写锁时都必须配合【戳】使用**

      **加解读锁**:

      ```java
      long stamp = lock.readLock();
      lock.unlockRead(stamp);

加解写锁

1
2
long stamp = lock.writeLock();
lock.unlockWrite(stamp);

乐观读,StampedLock 支持 tryOptimisticRead() 方法(乐观读),读取完毕后需要做一次 戳校验 如果校验通过,表示这期间确实没有写操作,数据可以安全使用,如果校验没通过,需要重新获取读锁,保证数据安全。

1
2
3
4
long stamp = lock.tryOptimisticRead(); // 验戳
if(!lock.validate(stamp)){
// 锁升级
}
2、StampedLock示例

提供一个 数据容器类 内部分别使用读锁保护数据的 read() 方法,写锁保护数据的 write() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j(topic = "c.DataContainerStamped")
class DataContainerStamped {
private int data;
private final StampedLock lock = new StampedLock();

public DataContainerStamped(int data) {
this.data = data;
}

public int read(int readTime) {
long stamp = lock.tryOptimisticRead();
log.debug("optimistic read locking...{}", stamp);
sleep(readTime);
if (lock.validate(stamp)) {
log.debug("read finish...{}, data:{}", stamp, data);
return data;
}
// 锁升级 - 读锁
log.debug("updating to read lock... {}", stamp);
try {
stamp = lock.readLock();
log.debug("read lock {}", stamp);
sleep(readTime);
log.debug("read finish...{}, data:{}", stamp, data);
return data;
} finally {
log.debug("read unlock {}", stamp);
lock.unlockRead(stamp);
}
}

public void write(int newData) {
long stamp = lock.writeLock();
log.debug("write lock {}", stamp);
try {
sleep(2);
this.data = newData;
} finally {
log.debug("write unlock {}", stamp);
lock.unlockWrite(stamp);
}
}
}

测试 读-读 (乐观读)

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j(topic = "c.TestStampedLock")
public class TestStampedLock {
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.read(0);
}, "t2").start();
}
}

输出结果,可以看到实际没有加读锁

1
2
3
4
15:58:50.217 c.DataContainerStamped [t1] - optimistic read locking...256 
15:58:50.717 c.DataContainerStamped [t2] - optimistic read locking...256
15:58:50.717 c.DataContainerStamped [t2] - read finish...256, data:1
15:58:51.220 c.DataContainerStamped [t1] - read finish...256, data:1

测试 读-写 时优化读补加读锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Slf4j(topic = "c.TestStampedLock")
public class TestStampedLock {
public static void main(String[] args) {
DataContainerStamped dataContainer = new DataContainerStamped(1);
new Thread(() -> {
dataContainer.read(1);
}, "t1").start();
sleep(0.5);
new Thread(() -> {
dataContainer.write(100);
}, "t2").start();
}
}

输出结果

1
2
3
4
5
6
7
15:57:00.219 c.DataContainerStamped [t1] - optimistic read locking...256 
15:57:00.717 c.DataContainerStamped [t2] - write lock 384
15:57:01.225 c.DataContainerStamped [t1] - updating to read lock... 256
15:57:02.719 c.DataContainerStamped [t2] - write unlock 384
15:57:02.719 c.DataContainerStamped [t1] - read lock 513
15:57:03.719 c.DataContainerStamped [t1] - read finish...513, data:1000
15:57:03.719 c.DataContainerStamped [t1] - read unlock 513
3、StampedLock是否可以替代ReentrantReadWriteLock

当然是不可以,虽然StampedLock在读写锁上的性能比ReentrantReadWriteLock好,但是它有以下几个缺点:

  • StampedLock 不支持条件变量
  • StampedLock 不支持可重入

7、线程间通信

线程间通信的模型有两种:==共享内存==和==消息传递==

1、场景

我们来基本一道面试常见的题目来分析:场景——四个线程,两个线程对当前数值加 1,另外两个线程对当前数值减 1,要求用线程间通信。

2、分析

1、关于i++与i–的字节码及其执行流程

i++其实是一个复合操作,包括三步骤:

  • 读取i的值。
  • 对i加1。
  • 将i的值写回内存。

i++的相关字节码指令:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
iadd //自增
putstatic i // 将修改后的值存入静态变量i

对于i–也是类似:

1
2
3
4
getstatic i // 获取静态变量i的值
iconst_ 1 //准备常量1
isub //自减
putstatic i // 将修改后的值存入静态变量i

而Java的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:(下图只显示了两个线程,分别做自增和自减)

image-20210804150447015

如果是单线程以上 8 行字节码是顺序执行(不会交错)没有问题:

image-20210804152755128

但多线程下这 8 行字节码可能交错运行:

出现负数的情况:

image-20210804152921570

出现正数的情况:

image-20210804152947980

2、临界区 Critical Section
  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
3、竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

3、解决方法

image-20210804154458987

image-20210804154522110

使用Lock的方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package com.atguigu.lock;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

//第一步 创建资源类,定义属性和操作方法
class Share {
private int number = 0;

//创建Lock
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();

//+1
public void incr() throws InterruptedException {
//上锁
lock.lock();
try {
//判断
while (number != 0) {
condition.await();
}
//干活
number++;
System.out.println(Thread.currentThread().getName()+" :: "+number);
//通知
condition.signalAll();
}finally {
//解锁
lock.unlock();
}
}

//-1
public void decr() throws InterruptedException {
lock.lock();
try {
while(number != 1) {
condition.await();
}
number--;
System.out.println(Thread.currentThread().getName()+" :: "+number);
condition.signalAll();
}finally {
lock.unlock();
}
}
}

public class ThreadDemo2 {

public static void main(String[] args) {
Share share = new Share();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.incr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"A").start();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.decr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"B").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.incr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"C").start();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
share.decr();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"D").start();
}
}

8、线程间定制化通信

案例介绍:

问题:A 线程打印 5 次 A,B 线程打印 10 次 B,C 线程打印 15 次 C,按照此顺序循环 10 轮。

实现方法:

image-20210722025741403

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
//第一步 创建资源类
class ShareResource {
//定义标志位
private int flag = 1; // 1 AA 2 BB 3 CC

//创建Lock锁
private Lock lock = new ReentrantLock();

//创建三个condition
private Condition c1 = lock.newCondition();
private Condition c2 = lock.newCondition();
private Condition c3 = lock.newCondition();

//打印5次,参数第几轮
public void print5(int loop) throws InterruptedException {
//上锁
lock.lock();
try {
//判断
while(flag != 1) {
//等待
c1.await();
}
//干活
for (int i = 1; i <=5; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//通知
flag = 2; //修改标志位 2
c2.signal(); //通知BB线程
}finally {
//释放锁
lock.unlock();
}
}

//打印10次,参数第几轮
public void print10(int loop) throws InterruptedException {
lock.lock();
try {
while(flag != 2) {
c2.await();
}
for (int i = 1; i <=10; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//修改标志位
flag = 3;
//通知CC线程
c3.signal();
}finally {
lock.unlock();
}
}

//打印15次,参数第几轮
public void print15(int loop) throws InterruptedException {
lock.lock();
try {
while(flag != 3) {
c3.await();
}
for (int i = 1; i <=15; i++) {
System.out.println(Thread.currentThread().getName()+" :: "+i+" :轮数:"+loop);
}
//修改标志位
flag = 1;
//通知AA线程
c1.signal();
}finally {
lock.unlock();
}
}
}

public class ThreadDemo3 {
public static void main(String[] args) {
ShareResource shareResource = new ShareResource();
new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print5(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"AA").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print10(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"BB").start();

new Thread(()->{
for (int i = 1; i <=10; i++) {
try {
shareResource.print15(i);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"CC").start();
}
}

9、集合的线程安全

类结构关系:

image

image-20210813203912863

线程安全集合类可以分为三大类:

  • 遗留的线程安全集合如 Hashtable , Vector
  • 使用 Collections 装饰的线程安全集合,如:
    • Collections.synchronizedCollection
    • Collections.synchronizedList
    • Collections.synchronizedMap
    • Collections.synchronizedSet
    • Collections.synchronizedNavigableMap
    • Collections.synchronizedNavigableSet
    • Collections.synchronizedSortedMap
    • Collections.synchronizedSortedSet
  • java.util.concurrent.*

重点介绍 java.util.concurrent.* 下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:BlockingCopyOnWriteConcurrent

  • Blocking 大部分实现基于锁,并提供用来阻塞的方法
  • CopyOnWrite 之类容器修改开销相对较重
  • Concurrent 类型的容器
    • 内部很多操作使用 cas 优化,一般可以提供较高吞吐量
    • 弱一致性
      • 遍历时弱一致性,例如,当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续进行遍历,这时内容是旧的
      • 求大小弱一致性,size 操作未必是 100% 准确
      • 读取弱一致性

遍历时如果发生了修改,对于非安全容器来讲,使用 fail-fast 机制也就是让遍历立刻失败,抛出ConcurrentModificationException,不再继续遍历

1、ArrayList不安全

ArrayList的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,ArrayList是不安全的。

示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadDemo4 {
public static void main(String[] args) {
//创建ArrayList集合
List<String> list = new ArrayList<>();

for (int i = 0; i <30; i++) {
new Thread(()->{
//向集合添加内容
list.add(UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(list);
},String.valueOf(i)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Exception in thread "27" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
at java.util.ArrayList$Itr.next(ArrayList.java:851)
at java.util.AbstractCollection.toString(AbstractCollection.java:461)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at test.ThreadDemo.lambda$main$0(ThreadDemo.java:17)
at java.lang.Thread.run(Thread.java:748)
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa, 7d121289]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db]
[cca2ec16, 6e8894c8, 30ace70b, 622ecfd3, 81951d12, 7e3fd2db, beca20aa, 7d121289]
...

解决ArrayList在多线程环境下不安全的问题:

  1. 方案1:用Vector代替ArrayList
  2. 方案2:Collections.synchronizedList创建一个同步的ArrayList
  3. 方案3:所以JUC的CopyOnWriteArrayList
1、方案1:用Vector代替ArrayList
1
2
// Vector解决
List<String> list = new Vector<>();

在Vector底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下Vector是安全的。

但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

2、方案2:Collections.synchronizedList创建一个同步的ArrayList
1
2
//Collections解决
List<String> list = Collections.synchronizedList(new ArrayList<>());

同理这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

那么有没有好的方法,既解决了ArrayList不安全的问题,又不会对程序的效率造成很大的影响?

答:方案3:所以JUC的CopyOnWriteArrayList

1
2
// CopyOnWriteArrayList解决
List<String> list = new CopyOnWriteArrayList<>();

2、JUC的CopyOnWriteArrayList

CopyOnWriteArraySet 是它的马甲 底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离

1、BAT大厂的面试问题
  • 请先说说非并发集合中Fail-fast机制?
  • 再为什么说ArrayList查询快而增删慢?
  • 对比ArrayList说说CopyOnWriteArrayList的增删改查实现原理?
    • COW基于拷贝
  • 再说下弱一致性的迭代器原理是怎么样的?
    • COWIterator<E>
  • CopyOnWriteArrayList为什么并发安全且性能比Vector好?
  • CopyOnWriteArrayList有何缺陷,说说其应用场景?
2、CopyOnWriteArrayList源码分析
1、类的继承关系

CopyOnWriteArrayList

  • 实现了List接口,List接口定义了对列表的基本操作;
  • 同时实现了RandomAccess接口,表示可以随机访问(数组具有随机访问的特性);
  • 同时实现了Cloneable接口,表示可克隆;
  • 同时也实现了Serializable接口,表示可被序列化。
1
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {}
2、类的内部类——COWIterator类

COWIterator表示迭代器,其也有一个Object类型的数组作为CopyOnWriteArrayList数组的快照,这种快照风格的迭代器方法在创建迭代器时使用了对当时数组状态的引用。此数组在迭代器的生存期内不会更改,因此不可能发生冲突,并且迭代器保证不会抛出 ConcurrentModificationException

创建迭代器以后,迭代器就不会反映列表的添加、移除或者更改。在迭代器上进行的元素更改操作(remove、set 和 add)不受支持。这些方法将抛出 UnsupportedOperationException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
// 快照
private final Object[] snapshot;
/** Index of element to be returned by subsequent call to next. */
// 游标
private int cursor;
// 构造函数
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
// 是否还有下一项
public boolean hasNext() {
return cursor < snapshot.length;
}
// 是否有上一项
public boolean hasPrevious() {
return cursor > 0;
}
// next项
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext()) // 不存在下一项,抛出异常
throw new NoSuchElementException();
// 返回下一项
return (E) snapshot[cursor++];
}

@SuppressWarnings("unchecked")
public E previous() {
if (! hasPrevious())
throw new NoSuchElementException();
return (E) snapshot[--cursor];
}

// 下一项索引
public int nextIndex() {
return cursor;
}

// 上一项索引
public int previousIndex() {
return cursor-1;
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code remove}
* is not supported by this iterator.
*/
// 不支持remove操作
public void remove() {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code set}
* is not supported by this iterator.
*/
// 不支持set操作
public void set(E e) {
throw new UnsupportedOperationException();
}

/**
* Not supported. Always throws UnsupportedOperationException.
* @throws UnsupportedOperationException always; {@code add}
* is not supported by this iterator.
*/
// 不支持add操作
public void add(E e) {
throw new UnsupportedOperationException();
}

@Override
public void forEachRemaining(Consumer<? super E> action) {
Objects.requireNonNull(action);
Object[] elements = snapshot;
final int size = elements.length;
for (int i = cursor; i < size; i++) {
@SuppressWarnings("unchecked") E e = (E) elements[i];
action.accept(e);
}
cursor = size;
}
}
3、类的属性

属性中有一个可重入锁,用来保证线程安全访问,还有一个Object类型的数组,用来存放具体的元素。当然,也使用到了反射机制CAS来保证原子性的修改lock域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 8673264195747942595L;
// 可重入锁
final transient ReentrantLock lock = new ReentrantLock();
// 对象数组,用于存放元素
private transient volatile Object[] array;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// lock域的内存偏移量
private static final long lockOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = CopyOnWriteArrayList.class;
lockOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("lock"));
} catch (Exception e) {
throw new Error(e);
}
}
}
4、类的构造函数
  • 默认构造函数

    • public CopyOnWriteArrayList() {
          // 设置数组
          setArray(new Object[0]);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20

      - `CopyOnWriteArrayList(Collection<? extends E>)`型构造函数——该构造函数用于创建一个按 collection 的迭代器返回元素的顺序包含指定 collection 元素的列表。

      - ```java
      public CopyOnWriteArrayList(Collection<? extends E> c) {
      Object[] elements;
      if (c.getClass() == CopyOnWriteArrayList.class) // 类型相同
      // 获取c集合的数组
      elements = ((CopyOnWriteArrayList<?>)c).getArray();
      else { // 类型不相同
      // 将c集合转化为数组并赋值给elements
      elements = c.toArray();
      // c.toArray might (incorrectly) not return Object[] (see 6260652)
      if (elements.getClass() != Object[].class) // elements类型不为Object[]类型
      // 将elements数组转化为Object[]类型的数组
      elements = Arrays.copyOf(elements, elements.length, Object[].class);
      }
      // 设置数组
      setArray(elements);
      }
    • CopyOnWriteArrayList(Collection<? extends E>)型构造函数的处理流程如下:

      1. 判断传入的集合c的类型是否为CopyOnWriteArrayList类型,若是,则获取该集合类型的底层数组(Object[]),并且设置当前CopyOnWriteArrayList的数组(Object[]数组),进入步骤③;否则,进入步骤②
      2. 将传入的集合转化为数组elements,判断elements的类型是否为Object[]类型(toArray方法可能不会返回Object类型的数组),若不是,则将elements转化为Object类型的数组。进入步骤③
      3. 设置当前CopyOnWriteArrayList的Object[]为elements。
  • CopyOnWriteArrayList(E[])型构造函数

    • 该构造函数用于创建一个保存给定数组的副本的列表。

    • public CopyOnWriteArrayList(E[] toCopyIn) {
          // 将toCopyIn转化为Object[]类型数组,然后设置当前数组
          setArray(Arrays.copyOf(toCopyIn, toCopyIn.length, Object[].class));
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28

      ###### 5、核心函数

      - copyOf
      - add
      - addIfAbsent
      - set
      - remove

      对于CopyOnWriteArrayList的函数分析,主要明白**Arrays.copyOf方法**即可理解CopyOnWriteArrayList其他函数的意义。

      ###### 6、核心函数分析——copyOf

      该函数用于**复制指定的数组,截取或用 null 填充(如有必要),以使副本具有指定的长度**。

      ```java
      public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
      @SuppressWarnings("unchecked")
      // 确定copy的类型(将newType转化为Object类型,将Object[].class转化为Object类型,判断两者是否相等,若相等,则生成指定长度的Object数组
      // 否则,生成指定长度的新类型的数组)
      T[] copy = ((Object)newType == (Object)Object[].class)
      ? (T[]) new Object[newLength]
      : (T[]) Array.newInstance(newType.getComponentType(), newLength);
      // 将original数组从下标0开始,复制长度为(original.length和newLength的较小者),复制到copy数组中(也从下标0开始)
      System.arraycopy(original, 0, copy, 0,
      Math.min(original.length, newLength));
      return copy;
      }
7、核心函数分析——add
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public boolean add(E e) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 元素数组
// 获取旧的数组
Object[] elements = getArray();
// 数组长度
int len = elements.length;
// 复制数组
// 拷贝新的数组(这里是比较耗时的操作,但不影响其它读线程)
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 存放元素e
// 添加新元素
newElements[len] = e;
// 设置数组
// 替换旧的数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

这里的源码版本是 Java 11,在 Java 1.8 中使用的是可重入锁而不是 synchronized

此函数用于将指定元素添加到此列表的尾部,处理流程如下(写时复制技术)(并发读,独立写)

  • 获取锁(保证多线程的安全访问),获取当前的Object数组,获取Object数组的长度为length,进入步骤②。
  • 根据Object数组复制一个长度为length+1的Object数组为newElements(此时,newElements[length]为null),进入下一步骤。
  • 将下标为length的数组元素newElements[length]设置为元素e,再设置当前Object[]为newElements,释放锁,返回。这样就完成了元素的添加。

其实就是写时复制技术,并发读,独立写

  • 读进程读的是原来的版本
  • 写进程写的是原来的版本的复制版本
  • 在写进程完成好写之后,再将复制的版本与原来的版本进行合并

image-20210723213936543

8、核心函数分析——addIfAbsent

该函数用于添加元素(如果数组中不存在,则添加;否则,不添加,直接返回),可以保证多线程环境下不会重复添加元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private boolean addIfAbsent(E e, Object[] snapshot) {
// 重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] current = getArray();
// 数组长度
int len = current.length;
if (snapshot != current) { // 快照不等于当前数组,对数组进行了修改
// Optimize for lost race to another addXXX operation
// 取较小者
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++) // 遍历
if (current[i] != snapshot[i] && eq(e, current[i])) // 当前数组的元素与快照的元素不相等并且e与当前元素相等
// 表示在snapshot与current之间修改了数组,并且设置了数组某一元素为e,已经存在
// 返回
return false;
if (indexOf(e, current, common, len) >= 0) // 在当前数组中找到e元素
// 返回
return false;
}
// 复制数组
Object[] newElements = Arrays.copyOf(current, len + 1);
// 对数组len索引的元素赋值为e
newElements[len] = e;
// 设置数组
setArray(newElements);
return true;
} finally {
// 释放锁
lock.unlock();
}
}

该函数的流程如下:

  1. 获取锁,获取当前数组为current,current长度为len,判断数组之前的快照snapshot是否等于当前数组current,若不相等,则进入步骤②;否则,进入步骤④
  2. 不相等,表示在snapshot与current之间,对数组进行了修改(如进行了add、set、remove等操作),获取长度(snapshot与current之间的较小者),对current进行遍历操作,若遍历过程发现snapshot与current的元素不相等并且current的元素与指定元素相等(可能进行了set操作),进入步骤⑤,否则,进入步骤③
  3. 在当前数组中索引指定元素,若能够找到,进入步骤⑤,否则,进入步骤④
  4. 复制当前数组current为newElements,长度为len+1,此时newElements[len]为null。再设置newElements[len]为指定元素e,再设置数组,进入步骤⑤
  5. 释放锁,返回。
9、核心函数分析——set

此函数用于用指定的元素替代此列表指定位置上的元素,也是基于数组的复制来实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public E set(int index, E element) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] elements = getArray();
// 获取index索引的元素
E oldValue = get(elements, index);

if (oldValue != element) { // 旧值不等于element
// 数组长度
int len = elements.length;
// 复制数组
Object[] newElements = Arrays.copyOf(elements, len);
// 重新赋值index索引的值
newElements[index] = element;
// 设置数组
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
// 设置数组
setArray(elements);
}
// 返回旧值
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}
10、核心函数分析——remove

此函数用于移除此列表指定位置上的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public E remove(int index) {
// 可重入锁
final ReentrantLock lock = this.lock;
// 获取锁
lock.lock();
try {
// 获取数组
Object[] elements = getArray();
// 数组长度
int len = elements.length;
// 获取旧值
E oldValue = get(elements, index);
// 需要移动的元素个数
int numMoved = len - index - 1;
if (numMoved == 0) // 移动个数为0
// 复制后设置数组
setArray(Arrays.copyOf(elements, len - 1));
else { // 移动个数不为0
// 新生数组
Object[] newElements = new Object[len - 1];
// 复制index索引之前的元素
System.arraycopy(elements, 0, newElements, 0, index);
// 复制index索引之后的元素
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 设置索引
setArray(newElements);
}
// 返回旧值
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}

处理流程如下:

  1. 获取锁,获取数组elements,数组长度为length,获取索引的值elements[index],计算需要移动的元素个数(length - index - 1),若个数为0,则表示移除的是数组的最后一个元素,复制elements数组,复制长度为length-1,然后设置数组,进入步骤③;否则,进入步骤②
  2. 先复制index索引前的元素,再复制index索引后的元素,然后设置数组。
  3. 释放锁,返回旧值。
3、CopyOnWriteArrayList示例

下面通过一个示例来了解CopyOnWriteArrayList的使用:

在程序中,有一个PutThread线程会每隔50ms就向CopyOnWriteArrayList中添加一个元素,并且两次使用了迭代器,迭代器输出的内容都是生成迭代器时,CopyOnWriteArrayList的Object数组的快照的内容,在迭代的过程中,往CopyOnWriteArrayList中添加元素也不会抛出异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

class PutThread extends Thread {
private CopyOnWriteArrayList<Integer> cowal;

public PutThread(CopyOnWriteArrayList<Integer> cowal) {
this.cowal = cowal;
}

public void run() {
try {
for (int i = 100; i < 110; i++) {
cowal.add(i);
Thread.sleep(50);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public class CopyOnWriteArrayListDemo {
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> cowal = new CopyOnWriteArrayList<Integer>();
for (int i = 0; i < 10; i++) {
cowal.add(i);
}
PutThread p1 = new PutThread(cowal);
p1.start();
Iterator<Integer> iterator = cowal.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
System.out.println();
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}

iterator = cowal.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + " ");
}
}
}

运行结果(某一次)

1
2
0 1 2 3 4 5 6 7 8 9 100 
0 1 2 3 4 5 6 7 8 9 100 101 102 103
4、CopyOnWriteArrayList的弱一致性体现
1、get 弱一致性

image-20210814045103054

时间点 操作
1 Thread-0 getArray()
2 Thread-1 getArray()
3 Thread-1 setArray(arrayCopy)
4 Thread-0 array[index]
2、迭代器弱一致性
1
2
3
4
5
6
7
8
9
10
11
12
13
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
list.add(1);
list.add(2);
list.add(3);
Iterator<Integer> iter = list.iterator();
new Thread(() -> {
list.remove(0);
System.out.println(list);
},"t1").start();
sleep1s();
while (iter.hasNext()) {
System.out.println(iter.next());
}

虽然线程t1已经将1从list中移除,但是迭代器当中迭代的list依旧是旧的list,有包括1

3、关于弱一致性

不要觉得弱一致性就不好

  • 数据库的 MVCC 都是弱一致性的表现
  • 并发高和一致性是矛盾的,需要权衡
5、更深入理解
1、CopyOnWriteArrayList的缺陷和使用场景

CopyOnWriteArrayList 有几个缺点:

  • 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致young gc或者full gc
  • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个set操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求;

CopyOnWriteArrayList 合适==读多写少==的场景,不过这类慎用

因为谁也没法保证CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次add/set都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。

2、CopyOnWriteArrayList为什么并发安全性能比Vector好?
  • Vector对单独的add,remove等方法都是在方法上加了synchronized;
  • 并且如果一个线程A调用size时,另一个线程B 执行了remove,然后size的值就不是最新的,然后线程A调用remove就会越界(这时就需要再加一个Synchronized)。这样就导致有了双重锁,效率大大降低,何必呢。
  • 于是vector废弃了,要用就用CopyOnWriteArrayList 吧。

3、HashMap不安全

HashMap的底层没有用synchronized修饰,本身也没有使用CAS等轻量级锁。所以在多线程环境下,HashMap是不安全的。

示例:(在一边添加一遍读取的时候,可能会出现内容还没有添加进去就被读取的情况,而且会报:java.util.ConcurrentModificationException)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadDemo4 {
public static void main(String[] args) {
//演示HashMap
Map<String,String> map = new HashMap<>();

for (int i = 0; i <30; i++) {
String key = String.valueOf(i);
new Thread(()->{
//向集合添加内容
map.put(key,UUID.randomUUID().toString().substring(0,8));
//从集合获取内容
System.out.println(map);
},String.valueOf(i)).start();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{22=b7638976, 23=918c6021, 24=254a542e, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 8=a8951500, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 28=85c91660, 29=930cd3b4, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
{22=b7638976, 23=918c6021, 24=254a542e, 25=9f469278, 26=effdaef0, 27=b0fd0006, 28=85c91660, 16=f7d3b9d2, 11=00b17c4e, 12=25056443, 2=759da0ba, 3=d977b33e, 4=758b21a1, 5=3d8e4168, 17=78fd5054, 18=d7bb38f0, 8=a8951500, 19=d65b26d7, 9=604d74ec, 20=5397c946, 6=c9eab94c, 7=05d8fd82, 10=7400865e, 21=dc0083c7}
Exception in thread "4" Exception in thread "0" Exception in thread "5" Exception in thread "17" Exception in thread "1" Exception in thread "7" Exception in thread "14" Exception in thread "19" Exception in thread "26" Exception in thread "27" java.util.ConcurrentModificationException
at java.util.HashMap$HashIterator.nextNode(HashMap.java:1437)
at java.util.HashMap$EntryIterator.next(HashMap.java:1471)
at java.util.HashMap$EntryIterator.next(HashMap.java:1469)
at java.util.AbstractMap.toString(AbstractMap.java:554)
at java.lang.String.valueOf(String.java:2994)
at java.io.PrintStream.println(PrintStream.java:821)
at test.ThreadDemo4.lambda$main$0(ThreadDemo4.java:20)
at java.lang.Thread.run(Thread.java:748)

解决HashMap在多线程环境下不安全的问题:

  1. 方案1:用HashTable代替HashMap
  2. 方案2:JUC的ConcurrentHashMap
方案1:用HashTable代替HashMap
1
2
// 用HashTable代替HashMap
Map<String,String> map = new HashTable<>();

在HashTable底层的几乎所有方法都有Synchronized进行修饰,所以在多线程下HashTable是安全的。

但是这种方法会导致程序的效率变得很低,所以一般不会使用这种方法。

那么有没有好的方法,既解决了HashMap不安全的问题,又不会对程序的效率造成很大的影响?

答:方案2:所以JUC的ConcurrentHashMap

1
2
// ConcurrentHashMap解决
Map<String,String> map = new ConcurrentHashMap<>();

4、JUC的ConcurrentHashMap

1、BAT大厂的面试问题
  • 为什么HashTable慢?它的并发度是什么?那么ConcurrentHashMap并发度是什么?
  • ConcurrentHashMap在JDK1.7和JDK1.8中实现有什么差别?JDK1.8解決了JDK1.7中什么问题?
  • ConcurrentHashMap JDK1.7实现的原理是什么?
    • 分段锁机制
  • ConcurrentHashMap JDK1.8实现的原理是什么?
    • 数组+链表+红黑树,CAS
  • ConcurrentHashMap JDK1.7中Segment数(concurrencyLevel)默认值是多少?为何一旦初始化就不可再扩容?
  • ConcurrentHashMap JDK1.7说说其put的机制?
  • ConcurrentHashMap JDK1.7是如何扩容的?
    • rehash(注:segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容)
  • ConcurrentHashMap JDK1.8是如何扩容的?
    • tryPresize
  • ConcurrentHashMap JDK1.8链表转红黑树的时机是什么?临界值为什么是8?
  • ConcurrentHashMap JDK1.8是如何进行数据迁移的?
    • transfer
  • JDK 7 HashMap 并发死链问题
2、为什么HashTable慢

Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。

3、ConcurrentHashMap - JDK1.7

在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap.

简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可实现多线程put操作。

  • 优点:如果多个线程访问不同的 segment,实际是没有冲突的,这与 jdk8 中是类似的
  • 缺点:Segments 数组默认大小为16,这个容量初始化指定后就不能改变了,并且不是懒惰初始化

接下来分析JDK1.7版本中ConcurrentHashMap的实现原理。

1、数据结构

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表“部分”或“一段”的意思,所以很多地方都会将其描述为分段锁。一般使用“槽”来代表一个 segment。

简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 Segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

img

concurrencyLevel:并行级别、并发数、Segment 数,怎么翻译不重要,重要的是理解它。默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,不过它要保证线程安全,所以处理起来要麻烦些。

2、初始化
  • initialCapacity初始容量。这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment
  • loadFactor负载因子。之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
// ssize 必须是 2^n, 即 2, 4, 8, 16 ... 表示了 segments 数组的大小
int sshift = 0;
int ssize = 1;
// 计算并行级别 ssize,因为要保持并行级别是 2 的 n 次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 我们这里先不要那么烧脑,用默认值,concurrencyLevel 为 16,sshift 为 4
// segmentShift 默认是 32 - 4 = 28
// 那么计算出 segmentShift 为 28,segmentMask 为 15,即 0000 0000 0000 1111后面会用到这两个值
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;// 掩码

if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;

// initialCapacity 是设置整个 map 初始的大小,
// 这里根据 initialCapacity 计算 Segment 数组中每个位置可以分到的大小
// 如 initialCapacity 为 64,那么每个 Segment 或称之为"槽"可以分到 4 个
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 默认 MIN_SEGMENT_TABLE_CAPACITY 是 2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
// 插入一个元素不至于扩容,插入第二个的时候才会扩容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;

// 创建 Segment 数组,
// 并创建数组的第一个元素 segment[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 往数组写入 segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}

初始化完成,我们得到了一个 Segment 数组。如下图所示:

image-20210814031243376

我们就当是用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:

  • Segment 数组长度为 16,不可以扩容
  • Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
  • 这里初始化了 segment[0],其他位置还是 null,至于为什么要初始化 segment[0],后面的代码会介绍
  • 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数掩码,这两个值马上就会用到

可以看到 ConcurrentHashMap 没有实现懒惰初始化,空间占用不友好

其中 this.segmentShift 和 this.segmentMask 的作用是决定将 key 的 hash 结果匹配到哪个 segment

例如,根据某一 hash 值求 segment 位置,先将高位向低位移动 this.segmentShift 位

image-20210814031359616

结果再与 this.segmentMask 做位于运算,最终得到 1010 即下标为 10 的 segment

image-20210814031416319

3、put过程分析

先看 put 的主流程,对于其中的一些关键细节操作,后面会进行详细介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 1. 计算 key 的 hash 值
int hash = hash(key);
// 2. 根据 hash 值找到 Segment 数组中的位置 j
// hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高 4 位,
// 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null,
// 获得 segment 对象, 判断是否为 null, 是则创建该 segment
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
// 这时不能确定是否真的为 null, 因为其它线程也发现该 segment 为 null,
// 因此在 ensureSegment 里用 cas 方式保证该 segment 安全性
s = ensureSegment(j);
// 3. 插入新值到 槽 s 中
// 进入 segment 的put 流程
return s.put(key, hash, value, false);
}

第一层皮很简单,根据 hash 值很快就能找到相应的 Segment,之后就是 Segment 内部的 put 操作了。

Segment 内部是由 数组+链表 组成的。segment 继承了可重入锁(ReentrantLock),它的 put 方法为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁
// 先看主流程,后面还会具体介绍这部分内容
// 尝试加锁
HashEntry<K,V> node = tryLock() ? null :
// 如果不成功, 进入 scanAndLockForPut 流程
// 如果是多核 cpu 最多 tryLock 64 次, 进入 lock 流程
// 在尝试期间, 还可以顺便看该节点在链表中有没有, 如果没有顺便创建出来
scanAndLockForPut(key, hash, value);
// 执行到这里 segment 已经被成功加锁, 可以安全执行
V oldValue;
try {
// 这个是 segment 内部的数组
HashEntry<K,V>[] tab = table;
// 再利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry<K,V> first = entryAt(tab, index);

// 下面这串 for 循环虽然很长,不过也很好理解,想想该位置没有任何元素和已经存在一个链表这两种情况
for (HashEntry<K,V> e = first;;) {
if (e != null) {
// 更新
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// 覆盖旧值
e.value = value;
++modCount;
}
break;
}
// 继续顺着链表走
e = e.next;
}
else {
// 新增
// 1) 之前等待锁时, node 已经被创建, next 指向链表头
// node 到底是不是 null,这个要看获取锁的过程,不过和这里都没有关系。
// 如果不为 null,那就直接将它设置为链表表头;如果是null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
// 2) 创建新 node
node = new HashEntry<K,V>(hash, key, value, first);

int c = count + 1;
// 3) 扩容
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 其实就是将新的节点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}

整体流程还是比较简单的,由于有独占锁的保护,所以 segment 内部的操作并不复杂。至于这里面的并发问题,我们稍后再进行介绍。

到这里 put 操作就结束了,接下来,我们说一说其中几步关键的操作。

4、初始化槽:ensureSegment

ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。

这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 这里看到为什么之前要初始化 segment[0] 了,
// 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k]
// 为什么要用“当前”,因为 segment[0] 可能早就扩容过了
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);

// 初始化 segment[k] 内部的数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // 再次检查一遍该槽是否被其他线程初始化了。

Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}

总的来说,ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制

5、获取写入锁:scanAndLockForPut

前面我们看到,在往某个 segment 中 put 的时候,首先会调用

1
node = tryLock() ? null : scanAndLockForPut(key, hash, value)

也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。

下面我们来具体分析这个方法中是怎么控制加锁的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node

// 循环获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}

这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。

这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node

6、扩容:rehash

重复一下,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍

由于扩容发生在 put 中,因为此时已经获得了锁,因此 rehash 时不需要考虑线程安全

首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值。

1
2
3
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析

该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 2 倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;

// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;

// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
// 过一遍链表, 尽可能把 rehash 后 idx 不变的节点重用(直接搬移,没有进行头插法)
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,剩余节点需要新建
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 扩容完成, 才加入新的节点
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
// 替换为新的 HashEntry table
table = newTable;
}

这里的扩容比之前的 HashMap 要复杂一些,代码难懂一点。上面有两个挨着的 for 循环,第一个 for 有什么用呢?

仔细一看发现,如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就是了,不需要做任何操作。

我觉得 Doug Lea 的这个想法也是挺有意思的,不过比较坏的情况就是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么这次遍历就有点浪费了。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。

7、get过程分析

相对于 put 来说,get 就很简单了。

  • 计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽”
  • 槽中也是一个数组,根据 hash 找到数组中具体的位置
  • 到这里是链表了,顺着链表进行查找即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
// 1. hash 值
int h = hash(key);
// u 为 segment 对象在数组中的偏移量
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 2. 根据 hash 找到对应的 segment
// s 即为 segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 3. 找到segment 内部数组相应位置的链表,遍历
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
8、size计算流程
  • 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
  • 如果不一样,进行重试,重试次数超过 3,将所有 segment 锁住,重新计算个数返回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public int size() {
// Try a few times to get accurate count. On failure due to
// continuous async changes in table, resort to locking.
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow; // true if size overflows 32 bits
long sum; // sum of modCounts
long last = 0L; // previous sum
int retries = -1; // first iteration isn't retry
try {
for (;;) {
if (retries++ == RETRIES_BEFORE_LOCK) {
// 超过重试次数, 需要创建所有 segment 并加锁
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
sum = 0L;
size = 0;
overflow = false;
for (int j = 0; j < segments.length; ++j) {
Segment<K,V> seg = segmentAt(segments, j);
if (seg != null) {
sum += seg.modCount;
int c = seg.count;
if (c < 0 || (size += c) < 0)
overflow = true;
}
}
// 在这里处理的很巧妙
// 计算元素个数前,先不加锁计算两次,如果前后两次结果如一样,认为个数正确返回
// 先判断最后得出来的结果是不是与上次得到的结果一致:
// 一致,跳出循环
// 不一致,将本次得到的结果设置为最后的结果,方面下一次的对比
// 重试次数超过 3,将所有 segment 锁住,重新计算个数返回
// 在上面if (retries++ == RETRIES_BEFORE_LOCK)块内上锁,在下面finally的if块中解锁
if (sum == last)
break;
last = sum;
}
} finally {
if (retries > RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
segmentAt(segments, j).unlock();
}
}
return overflow ? Integer.MAX_VALUE : size;
}
9、并发问题分析

现在我们已经说完了 put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。

添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。

  • put 操作的线程安全性:
    • 初始化槽,这个我们之前就说过了,使用了 CAS 来初始化 Segment 中的数组
    • 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 **get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject**。
    • 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 ==table 使用了 volatile 关键字==。
  • remove 操作的线程安全性:
    • remove 操作我们没有分析源码,所以这里说的读者感兴趣的话还是需要到源码中去求实一下的。
    • get 操作需要遍历链表,但是 remove 操作会”破坏”链表
    • 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题
    • 如果 remove 先破坏了一个节点,分两种情况考虑。
      1. 如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
      2. 如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的
4、ConcurrentHashMap - JDK1.8

在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现

1、数据结构

img

结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。

重要属性和内部类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 默认为 0
// 当初始化时, 为 -1
// 当扩容时, 为 -(1 + 扩容线程数)
// 当初始化或扩容完成后,为 下一次的扩容的阈值大小,也就是容量的3/4
private transient volatile int sizeCtl;

// 整个 ConcurrentHashMap 就是一个 Node[]
// Node里面的属性:键。值、hash码、next
static class Node<K,V> implements Map.Entry<K,V> {}

// hash 表
transient volatile Node<K,V>[] table;

// 扩容时的 新 hash 表
private transient volatile Node<K,V>[] nextTable;

// 扩容时如果某个 bin 迁移完毕, 用 ForwardingNode 作为旧 table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}

// 用在 compute 以及 computeIfAbsent 时, 用来占位, 计算完成后替换为普通
Node static final class ReservationNode<K,V> extends Node<K,V> {}

// 作为 treebin 的头节点, 存储 root 和 first
static final class TreeBin<K,V> extends Node<K,V> {}

// 作为 treebin 的节点, 存储 parent, left, right
static final class TreeNode<K,V> extends Node<K,V> {}

相关的重要方法

1
2
3
4
5
6
7
8
// 获取 Node[] 中第 i 个 Node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i)

// cas 修改 Node[] 中第 i 个 Node 的值, c 为旧值, v 为新值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v)

// 直接修改 Node[] 中第 i 个 Node 的值, v 为新值
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v)
2、初始化
1
2
3
4
5
6
7
8
9
10
11
// 这构造函数里,什么都不干
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}

这个初始化方法有点意思,通过提供初始容量,计算了 sizeCtl:sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。

sizeCtl 这个属性使用的场景很多,不过只要跟着文章的思路来,就不会被它搞晕了。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 有参构造
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
// tableSizeFor 仍然是保证计算的大小是 2^n, 即 16,32,64 ...
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}

可以看到实现了懒惰初始化,在构造方法中仅仅计算了 table 的大小,以后在第一次使用时才会真正创建

3、put过程分析

仔细地一行一行代码看下去:(以下数组简称(table),链表简称(bin))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 得到 hash 值
// 其中 spread 方法会综合高位低位, 具有更好的 hash 性
int hash = spread(key.hashCode());
// 用于记录相应链表的长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// f 是链表头节点
// fh 是链表头结点的 hash
// i 是链表在 table 中的下标
Node<K,V> f; int n, i, fh;
// 要创建 table
// 如果数组"空",进行数组初始化
if (tab == null || (n = tab.length) == 0)
// 初始化数组,后面会详细介绍
// 初始化 table 使用了 cas, 无需 synchronized 创建成功, 进入下一轮循环
tab = initTable();

// 找该 hash 值对应的数组下标,得到第一个节点 f
// 要创建链表头节点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空,
// 用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了,可以拉到最后面了
// 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// hash 居然可以等于 MOVED,这个需要到后面才能看明白,不过从名字上也能猜到,肯定是因为在扩容
// 帮忙扩容
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移,这个等到看完数据迁移部分的介绍后,再理解这个就很简单了、
// 帮忙之后, 进入下一轮循环
tab = helpTransfer(tab, f);

else { // 到这里就是说,f 是该位置的头结点,而且不为空

V oldVal = null;
// 获取数组该位置的头结点的监视器锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 头结点的 hash 值大于 0,说明是链表
// 用于累加,记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 到了链表的最末端,将这个新值放到链表的最后面
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) { // 红黑树
Node<K,V> p;
binCount = 2;
// 调用红黑树的插值方法插入新节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}

if (binCount != 0) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
// 具体源码我们就不看了,扩容部分后面说
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 增加 size 计数
addCount(1L, binCount);
return null;
}

addCount()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// check 是之前 binCount 的个数(也就是链表的长度)
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if (
// 已经有了 counterCells, 向 cell 累加
(as = counterCells) != null ||
// 还没有, 向 baseCount 累加
U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)
) {
CounterCell a; long v; int m;
boolean uncontended = true;
if (
// 还没有 counterCells
as == null || (m = as.length - 1) < 0 ||
// 还没有 cell
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
// cell cas 增加计数失败
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
) {
// 创建累加单元数组和cell, 累加重试
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 获取元素个数
s = sumCount();
}
// 可能需要扩容
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// newtable 已经创建了,帮忙扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 需要扩容,这时 newtable 未创建
else if (U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}

这个增加size计数的方法与LongAdder的原理有点像:都是采用的分段累加的思想

它还有一个功能就是:扩容

4、初始化数组:initTable

这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。

初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 初始化的"功劳"被其他线程"抢去"了
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁(表示初始化 table)
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 获得锁, 创建 table, 这时其它线程会在 while() 循环中 yield 直至 table 创建
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组,长度为 16 或初始化时提供的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将这个数组赋值给 table,table 是 volatile 的
table = tab = nt;
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 设置 sizeCtl 为 sc,我们就当是 12 吧
sizeCtl = sc;
}
break;
}
}
return tab;
}
5、链表转红黑树:treeifyBin

前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容

我们还是进行源码分析吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// MIN_TREEIFY_CAPACITY 为 64
// 所以,如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 后面我们再详细分析这个方法
tryPresize(n << 1);
// b 是头结点
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 加锁
synchronized (b) {

if (tabAt(tab, index) == b) {
// 下面就是遍历链表,建立一颗红黑树
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将红黑树设置到数组相应位置中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
6、扩容:tryPresize

如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。

这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。

这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了(n << 1)
private final void tryPresize(int size) {
// c: size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;

// 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2); // 0.75 * n
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
// 我没看懂 rs 的真正含义是什么,不过也关系不大
int rs = resizeStamp(n);

if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 2. 用 CAS 将 sizeCtl 加 1,然后执行 transfer 方法
// 此时 nextTab 不为 null
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
// 我是没看懂这个值真正的意义是什么? 不过可以计算出来的是,结果是一个比较大的负数
// 调用 transfer 方法,此时 nextTab 参数为 null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}

这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。

所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。

7、数据迁移:transfer

下面这个方法有点长,将原来的 tab 数组的元素迁移到新的 nextTab 数组中。

虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。

此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。

阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。

第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;

// stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
// stride 可以理解为”步长“,有 n 个位置是需要进行迁移的,
// 将这 n 个任务分为多个任务包,每个任务包有 stride 个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range

// 如果 nextTab 为 null,先进行一次初始化
// 前面我们说了,外围会保证第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
// 之后参与迁移的线程调用此方法时,nextTab 不会为 null
if (nextTab == null) {
try {
// 容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
// nextTable 是 ConcurrentHashMap 中的属性
nextTable = nextTab;
// transferIndex 也是 ConcurrentHashMap 的属性,用于控制迁移的位置
transferIndex = n;
}

int nextn = nextTab.length;

// ForwardingNode 翻译过来就是正在被迁移的 Node
// 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
// 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
// 就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
// 所以它其实相当于是一个标志。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);


// advance 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab

/*
* 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
*
*/

// i 是位置索引,bound 是边界,注意是从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;

// 下面这个 while 真的是不好理解
// advance 为 true 表示可以进行下一个位置的迁移了
// 简单理解结局: i 指向了 transferIndex,bound 指向了 transferIndex-stride
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;

// 将 transferIndex 值赋给 nextIndex
// 这里 transferIndex 一旦小于等于 0,说明原数组的所有位置都有相应的线程去处理了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 看括号中的代码,nextBound 是这次迁移任务的边界,注意,是从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
// 所有的迁移操作已经完成
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 重新计算 sizeCtl: n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}

// 之前我们说过,sizeCtl 在迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2
// 然后,每有一个线程参与迁移就会将 sizeCtl 加 1,
// 这里使用 CAS 操作对 sizeCtl 进行减 1,代表做完了属于自己的任务
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 任务结束,方法退出
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;

// 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 也就是说,所有的迁移任务都做完了,也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 该位置处是一个 ForwardingNode,代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 头结点的 hash 大于 0,说明是链表的 Node 节点
if (fh >= 0) {
// 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
// 需要将链表一分为二,
// 找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
// lastRun 之前的节点需要进行克隆,然后分到两个链表中
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 其中的一个链表放在新数组的位置 i
setTabAt(nextTab, i, ln);
// 另一个链表放在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
else if (f instanceof TreeBin) {
// 红黑树的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;

// 将 ln 放置在新数组的位置 i
setTabAt(nextTab, i, ln);
// 将 hn 放置在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
}
}
}
}
}

说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制

这个时候,再回去仔细看 tryPresize 方法可能就会更加清晰一些了。

8、get过程分析

get 方法从来都是最简单的,这里也不例外:

  • 计算 hash 值
  • 根据 hash 值找到数组对应位置:(n - 1) & h
  • 根据该位置处结点性质进行相应查找
    • 如果该位置为 null,那么直接返回 null 就可以了
    • 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
    • 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
    • 如果以上 3 条都不满足,那就是链表,进行遍历比对即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// spread 方法能确保返回结果是正数
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 判断头结点是否就是我们需要的节点
if ((eh = e.hash) == h) { // 如果头结点已经是要查找的 key
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果头结点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树
// hash 为负数表示该 bin 在扩容中或是 treebin, 这时调用 find 方法来查找
else if (eh < 0)
// 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k)
return (p = e.find(h, key)) != null ? p.val : null;

// 遍历链表
// 正常遍历链表, 用 equals 比较
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}

简单说一句,此方法的大部分内容都很简单,只有正好碰到扩容的情况,ForwardingNode.find(int h, Object k) 稍微复杂一些,不过在了解了数据迁移的过程后,这个也就不难了,所以限于篇幅这里也不展开说了。

9、size 计算流程

size 计算实际发生在 put,remove 改变集合元素的操作之中

  • 没有竞争发生,向 baseCount 累加计数
  • 有竞争发生,新建 counterCells,向其中的一个 cell 累加计数
    • counterCells 初始有两个 cell
    • 如果计数竞争比较激烈,会创建新的 cell 来累加计数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}

final long sumCount() {
CounterCell[] as = counterCells;
CounterCell a;
// 将 baseCount 计数与所有 cell 计数累加
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
10、总结

Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)

  • 初始化,使用 cas 来保证并发安全,懒惰初始化 table
  • 树化,当 table.length < 64 时,先尝试扩容,超过 64 时,并且 bin.length > 8 时,会将链表树化,树化过程会用 synchronized 锁住链表头
  • put,如果该 bin 尚未创建,只需要使用 cas 创建 bin;如果已经有了,锁住链表头进行后续 put 操作,元素添加至 bin 的尾部
  • get,无锁操作仅需要保证可见性,扩容过程中 get 操作拿到的是 ForwardingNode 它会让 get 操作在新 table 进行搜索
  • 扩容,扩容时以 bin 为单位进行,需要对 bin 进行 synchronized,但这时妙的是其它竞争线程也不是无事可做,它们会帮助把其它 bin 进行扩容,扩容时平均只有 1/6 的节点会把复制到新 table 中
  • size,元素个数保存在 baseCount 中,并发时的个数变动保存在 CounterCell[] 当中。最后统计数量时累加即可
5、对比总结
  • HashTable:使用了synchronized关键字对put等操作进行加锁;
  • ConcurrentHashMap JDK1.7:使用分段锁机制实现;
  • ConcurrentHashMap JDK1.8:则使用数组+链表+红黑树数据结构和CAS原子操作实现;
6、正确使用ConcurrentHashMap——computeIfAbsent()方法

示例:单词计数

生成测试数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static final String ALPHA = "abcedfghijklmnopqrstuvwxyz";

public static void main(String[] args) {
int length = ALPHA.length();
int count = 200;
List<String> list = new ArrayList<>(length * count);
for (int i = 0; i < length; i++) {
char ch = ALPHA.charAt(i);
for (int j = 0; j < count; j++) {
list.add(String.valueOf(ch));
}
}
Collections.shuffle(list);
for (int i = 0; i < 26; i++) {
try (PrintWriter out = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream("tmp/" + (i+1) + ".txt")))) {
String collect = list.subList(i * count, (i + 1) * count).stream()
.collect(Collectors.joining("\n"));
out.print(collect);
} catch (IOException e) {
}
}
}

模版代码,模版代码中封装了多线程读取文件的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static <V> void demo(Supplier<Map<String,V>> supplier, BiConsumer<Map<String,V>,List<String>> consumer) {
Map<String, V> counterMap = supplier.get();
List<Thread> ts = new ArrayList<>();
for (int i = 1; i <= 26; i++) {
int idx = i;
Thread thread = new Thread(() -> {
List<String> words = readFromFile(idx);
consumer.accept(counterMap, words);
});
ts.add(thread);
}
ts.forEach(t->t.start());
ts.forEach(t-> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(counterMap);
}

public static List<String> readFromFile(int i) {
ArrayList<String> words = new ArrayList<>();
try (BufferedReader in = new BufferedReader(new InputStreamReader(new FileInputStream("tmp/" + i +".txt")))) {
while(true) {
String word = in.readLine(); if(word == null) {
break;
}
words.add(word);
}
return words;
} catch (IOException e) {
throw new RuntimeException(e);
}
}

你要做的是实现两个参数

  • 一是提供一个 map 集合,用来存放每个单词的计数结果,key 为单词,value 为计数
  • 二是提供一组操作,保证计数的安全性,会传递 map 集合以及 单词 List

正确结果输出应该是每个单词出现 200 次

1
{a=200, b=200, c=200, d=200, e=200, f=200, g=200, h=200, i=200, j=200, k=200, l=200, m=200, n=200, o=200, p=200, q=200, r=200, s=200, t=200, u=200, v=200, w=200, x=200, y=200, z=200} 

下面的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
demo(
// 创建 map 集合
// 创建 ConcurrentHashMap 对不对?
() -> new HashMap<String, Integer>(),
// 进行计数
(map, words) -> {
for (String word : words) {
Integer counter = map.get(word);
int newValue = counter == null ? 1 : counter + 1;
map.put(word, newValue);
}
}
);

有没有问题?请改进

问题:使用HashMap,线程不安全

改进:使用ConcurrentHashMap替换HashMap

运行发现问题:就算加上了ConcurrentHashMap也不能保证线程安全

原因:原因不难发现,ConcurrentHashMap只是保证了单一操作的线程安全,但是单一线程的组合并不保证线程安全。我们可以发现:

  • Integer counter = map.get(word);:根据Key(word单词)获取Value(计数)——读操作
  • int newValue = counter == null ? 1 : counter + 1; map.put(word, newValue);
    • 如果Key(单词)存在,则计数加1
    • 如果Key(单词)不存在,则计数为1
    • 在将结果的Key与Value放入Map容器当中——写操作

虽然ConcurrentHashMap能保证单一的读操作或单一读操作的线程安全,但是读操作与写操作的组合并不能保证线程安全

  • 解决方法1:将读操作与写操作一起加入Synchronized(map)同步代码块当中

    • 缺点:锁的粒度太大,线程的效率降低
  • 解决方法2:使用ConcurrentHashMap的computeIfAbsent()方法

    • 注意:

      • 累加操作也是需要保证线程安全性,所以使用的是LongAdder累加器来完成累加操作
      • 注意不能使用 putIfAbsent,此方法返回的是上一次的 value,首次调用返回 null
    • demo(
          // 创建 map 集合
          // 创建 ConcurrentHashMap 对不对?
          () -> new ConcurrentHashMap<String, LongAdder>(8,0.75f,8),
      
          (map, words) -> {
              for (String word : words) {
      
                  // 如果缺少一个 key,则计算生成一个 value , 然后将  key value 放入 map
                  //                  a      0
                  LongAdder value = map.computeIfAbsent(word, (key) -> new LongAdder());
                  // 执行累加
                  value.increment(); // 2
      
                  /*// 检查 key 有没有
                              Integer counter = map.get(word);
                              int newValue = counter == null ? 1 : counter + 1;
                              // 没有 则 put
                              map.put(word, newValue);*/
              }
          }
      );
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 解决方法3:使用函数式编程,无需原子变量——使用ConcurrentHashMap的merge方法

      - ```java
      demo(
      () -> new ConcurrentHashMap<String, Integer>(),
      (map, words) -> {
      for (String word : words) {
      // 函数式编程,无需原子变量
      map.merge(word, 1, Integer::sum);
      }
      }
      );
7、JDK 7 HashMap 并发死链问题原理
1、JDK 7 HashMap 并发死链问题

JDK 7 HashMap会出现死链问题的原因:JDK 7 HashMap的扩容数组的方法

  • JDK 7 HashMap使用的是头插法进行扩容数组的(JDK 8 HashMap使用的是尾插法——“七上八下”)
  • 在多线程下,就有可能出现死链问题
    • 实际上就是一个线程在扩容时把链表节点倒过来了,而另一个线程在扩容时正好也在前一个节点,就死循环了

下面使用一些测试代码和用debug的模式来验证JDK 7 HashMap 并发死链问题

注意:

  • 要在 JDK 7 下运行,否则扩容机制和 hash 的计算方法都变了
  • 以下测试代码是精心准备的,不要随便改动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.HashMap;

public class TestDeadLink {
public static void main(String[] args) {
// 测试 java 7 中哪些数字的 hash 结果相等
System.out.println("长度为16时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 16 == 1) {
System.out.println(i);
}
}
System.out.println("长度为32时,桶下标为1的key");
for (int i = 0; i < 64; i++) {
if (hash(i) % 32 == 1) {
System.out.println(i);
}
}
// 1, 35, 16, 50 当大小为16时,它们在一个桶内
final HashMap<Integer, Integer> map = new HashMap<Integer, Integer>();
// 放 12 个元素
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
map.put(6, null);
map.put(7, null);
map.put(8, null);
map.put(9, null);
map.put(10, null);
map.put(16, null);
map.put(35, null);
map.put(1, null);

System.out.println("扩容前大小[main]:"+map.size());
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null);
System.out.println("扩容后大小[Thread-0]:"+map.size());
}
}.start();
new Thread() {
@Override
public void run() {
// 放第 13 个元素, 发生扩容
map.put(50, null);
System.out.println("扩容后大小[Thread-1]:"+map.size());
}
}.start();
}

final static int hash(Object k) {
int h = 0;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
}
2、死链复现

调试工具使用 idea

在 HashMap 源码 590 行加断点

1
int newCapacity = newTable.length;

断点的条件如下,目的是让 HashMap 在扩容为 32 时,并且线程为 Thread-0 或 Thread-1 时停下来

1
2
3
4
5
newTable.length==32 && 
(
Thread.currentThread().getName().equals("Thread-0")||
Thread.currentThread().getName().equals("Thread-1")
)

断点暂停方式选择 Thread,否则在调试 Thread-0 时,Thread-1 无法恢复运行运行代码,程序在预料的断点位置停了下来,输出

1
2
3
4
5
6
7
8
9
长度为16时,桶下标为1的key 
1
16
35
50
长度为32时,桶下标为1的key
1
35
扩容前大小[main]:12

接下来进入扩容流程调试

在 HashMap 源码 594 行加断点

1
2
3
Entry<K,V> next = e.next; // 593 
if (rehash) // 594
// ...

这是为了观察 e 节点和 next 节点的状态,Thread-0 单步执行到 594 行,再 594 处再添加一个断点,条件为

1
Thread.currentThread().getName().equals("Thread-0")

这时可以在 Variables 面板观察到 e 和 next 变量,使用 view as -> Object 查看节点状态

1
2
e		(1)->(35)->(16)->null
next (35)->(16)->null

在 Threads 面板选中 Thread-1 恢复运行,可以看到控制台输出新的内容如下,Thread-1 扩容已完成

1
2
newTable[1]  (35)->(1)->null 
扩容后大小:13

这时 Thread-0 还停在 594 处, Variables 面板变量的状态已经变化为

1
2
e		(1)->null
next (35)->(1)->null

为什么呢,因为 Thread-1 扩容时链表也是后加入的元素放入链表头,因此链表就倒过来了,但 Thread-1 虽然结果正确,但它结束后 Thread-0 还要继续运行

接下来就可以单步调试(F8)观察死链的产生了

下一轮循环到 594,将 e 搬迁到 newTable 链表头

1
2
3
newTable[1]		(35)->(1)->null 
e (1)->null
next (1)->null

下一轮循环到 594,将 e 搬迁到 newTable 链表头

1
2
3
newTable[1]		(35)->(1)->null 
e (1)->null
next null

再看看源码

1
2
3
4
5
6
7
e.next = newTable[1];
// 这时 e (1,35)
// 而 newTable[1] (35,1)->(1,35) 因为是同一个对象
newTable[1] = e;
// 再尝试将 e 作为链表头, 死链已成
e = next;
// 虽然 next 是 null, 会进入下一个链表的复制, 但死链已经形成了
3、通过JDK 7 HashMap源码分析死链问题

HashMap 的并发死链发生在扩容时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 将 table 迁移至 newTable
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
// 1 处
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 2 处
// 将新元素加入 newTable[i], 原 newTable[i] 作为新元素的
next e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}

假设 map 中初始元素是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
原始链表,格式:[下标] (key,next)
[1] (1,35)->(35,16)->(16,null)
线程 a 执行到 1 处 ,此时局部变量 e 为 (1,35),而局部变量 next 为 (35,16) 线程 a 挂起
线程 b 开始执行
第一次循环
[1] (1,null)
第二次循环
[1] (35,1)->(1,null)
第三次循环
[1] (35,1)->(1,null)
[17] (16,null)
切换回线程 a,此时局部变量 e 和 next 被恢复,引用没变但内容变了:e 的内容被改为 (1,null),而 next 的内容被改为 (35,1) 并链向 (1,null)
第一次循环
[1] (1,null)
第二次循环,注意这时 e 是 (35,1) 并链向 (1,null) 所以 next 又是 (1,null)
[1] (35,1)->(1,null)
第三次循环,e 是 (1,null),而 next 是 null,但 e 被放入链表头,这样 e.next 变成了 35 (2 处)
[1] (1,35)->(35,1)->(1,35)
已经是死链了
4、小结
  • 究其原因,是因为在多线程环境下使用了非线程安全的 map 集合
  • JDK 8 虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序)(尾插法),但仍不意味着能够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)

5、HashSet不安全

同理,HashSet在多线程的环境下也是不安全的。解决方法:JUC的CopyOnWriteArraySet

CopyOnWriteArraySet:对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList

10、JUC并发集合:BlockingQueue接口(阻塞队列)

JUC里的 BlockingQueue 接口表示一个线程安全放入和提取实例的队列。下面将给你演示如何使用这个 BlockingQueue,不会讨论如何在 Java 中实现一个你自己的 BlockingQueue。

1、BAT大厂的面试问题

  • 什么是BlockingDeque?
  • BlockingQueue大家族有哪些?
    • ArrayBlockingQueue, DelayQueue, LinkedBlockingQueue, SynchronousQueue…
  • BlockingQueue适合用在什么样的场景?
  • BlockingQueue常用的方法?
  • BlockingQueue插入方法有哪些?这些方法(add(o),offer(o),put(o),offer(o, timeout, timeunit))的区别是什么?
  • BlockingDeque 与BlockingQueue有何关系,请对比下它们的方法?
  • BlockingDeque适合用在什么样的场景?
  • BlockingDeque大家族有哪些?
  • BlockingDeque 与BlockingQueue实现例子?

2、BlockingQueue和BlockingDeque

1、BlockingQueue
1、什么是BlockQueue?

BlockingQueue 通常用于==一个线程生产对象,而另外一个线程消费这些对象==的场景。下图是对这个原理的阐述:

img

线程1往阻塞队列里添加元素,线程2从阻塞队列里移除元素。

  • 一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的
  • 如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。
  • 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。
2、为什么需要BlockQueue?
  • 好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,因为这一切BlockingQueue 都给你一手包办了
  • 在 concurrent 包发布以前,在多线程环境下,我们每个程序员都必须去自己控制这些细节,尤其还要兼顾效率和线程安全,而这会给我们的程序带来不小的复杂度。
3、适用场景——经典的“生产者”和 “消费者”模型

在多线程环境中,通过队列可以很容易实现数据共享,比如经典的“生产者”和 “消费者”模型中,通过队列可以很便利地实现两者之间的数据共享。

假设我们有若干生产者线程,另外又有若干个消费者线程。如果生产者线程需要把准备好的数据共享给消费者线程,利用队列的方式来传递数据,就可以很方便地解决他们之间的数据共享问题。

但如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,那么生产者必须暂停等待一下(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕,反之亦然。

  • 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列
  • 队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒
2、BlockingQueue的方法

BlockingQueue 具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

抛异常 特定值 阻塞 超时
插入 add(o) offer(o) put(o) offer(o, timeout, timeunit)
移除 remove(o) poll(o) take(o) poll(timeout, timeunit)
检查 element(o) peek(o)

四组不同的行为方式解释:

  • 抛异常:如果试图的操作无法立即执行,抛一个异常。
  • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
  • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
  • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。

BlockingQueue 的核心方法:

  • 放入数据
    1. offer(anObject):表示如果可能的话,将 anObject 加到 BlockingQueue 里,即如果 BlockingQueue 可以容纳,则返回 true,否则返回 false。(本方法不阻塞当前执行方法的线程
    2. offer(E o, long timeout, TimeUnit unit):可以设定等待的时间,如果在指定的时间内,还不能往队列中加入 BlockingQueue,则返回失败。
    3. put(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程被阻断直到 BlockingQueue 里面有空间再继续。
    4. add(anObject):把 anObject 加到 BlockingQueue 里,如果 BlockQueue 没有空间,则调用此方法的线程抛出一个异常:Queue full。
  • 获取数据
    1. poll(time):取走 BlockingQueue 里排在首位的对象,若不能立即取出,则可以等time 参数规定的时间,取不到时返回 null
    2. poll(long timeout, TimeUnit unit):从 BlockingQueue 取出一个队首的对象,如果在指定时间内,队列一旦有数据可取,则立即返回队列中的数据。否则知道时间超时还没有数据可取,返回null。
    3. take():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,阻断进入等待状态直到 BlockingQueue 有新的数据被加入
    4. remove():取走 BlockingQueue 里排在首位的对象,若 BlockingQueue 为空,抛出一个异常:NoSuchElementException
    5. drainTo():一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定
      获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。

注意:

  • 无法向一个 BlockingQueue 中插入 null。如果你试图插入 null,BlockingQueue 将会抛出一个 NullPointerException。
  • 可以访问到 BlockingQueue 中的所有元素,而不仅仅是开始和结束的元素。比如说,你将一个对象放入队列之中以等待处理,但你的应用想要将其取消掉。那么你可以调用诸如 remove(o) 方法来将队列之中的特定对象进行移除。
  • 但是这么干效率并不高(译者注:基于队列的数据结构,获取除开始或结束位置的其他对象的效率不会太高),因此你尽量不要用这一类的方法,除非你确实不得不那么做。
3、BlockingDeque

java.util.concurrent 包里的 BlockingDeque 接口表示一个线程安全放入和提取实例的双端队列。

BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

使用情景:

  • 在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque
  • 如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。

BlockingDeque 图解:

img

4、BlockingDeque的方法

一个 BlockingDeque - 线程在双端队列的两端都可以插入和提取元素。 一个线程生产元素,并把它们插入到队列的任意一端。如果双端队列已满,插入线程将被阻塞,直到一个移除线程从该队列中移出了一个元素。如果双端队列为空,移除线程将被阻塞,直到一个插入线程向该队列插入了一个新元素。

BlockingDeque 具有 4 组不同的方法用于插入、移除以及对双端队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:

抛异常 特定值 阻塞 超时
插入 addFirst(o) offerFirst(o) putFirst(o) offerFirst(o, timeout, timeunit)
移除 removeFirst(o) pollFirst(o) takeFirst(o) pollFirst(timeout, timeunit)
检查 getFirst(o) peekFirst(o)
抛异常 特定值 阻塞 超时
插入 addLast(o) offerLast(o) putLast(o) offerLast(o, timeout, timeunit)
移除 removeLast(o) pollLast(o) takeLast(o) pollLast(timeout, timeunit)
检查 getLast(o) peekLast(o)

四组不同的行为方式解释:

  • 抛异常:如果试图的操作无法立即执行,抛一个异常。
  • 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
  • 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
  • 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
5、BlockingQueue和BlockingDeque的关系

BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。

以下是 BlockingDeque 对 BlockingQueue 接口的方法的具体内部实现:

BlockingQueue BlockingDeque
add() addLast()
offer() x 2 offerLast() x 2
put() putLast()
remove() removeFirst()
poll() x 2 pollFirst()
take() takeFirst()
element() getFirst()
peek() peekFirst()

3、BlockingQueue的例子

这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue 实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class BlockingQueueExample {

public static void main(String[] args) throws Exception {

BlockingQueue queue = new ArrayBlockingQueue(1024);

Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);

new Thread(producer).start();
new Thread(consumer).start();

Thread.sleep(4000);
}
}

以下是 Producer 类。注意它在每次 put() 调用时是为何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Producer implements Runnable{

protected BlockingQueue queue = null;

public Producer(BlockingQueue queue) {
this.queue = queue;
}

public void run() {
try {
queue.put("1");
Thread.sleep(1000);
queue.put("2");
Thread.sleep(1000);
queue.put("3");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Consumer implements Runnable{

protected BlockingQueue queue = null;

public Consumer(BlockingQueue queue) {
this.queue = queue;
}

public void run() {
try {
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
**1、数组阻塞队列——ArrayBlockingQueue**(常用)

ArrayBlockingQueue 类实现了 BlockingQueue 接口。

ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里

  • 有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。
  • 你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注: 因为它是基于数组实现的,也就具有数组的特性:一旦初始化,大小就无法修改)。
  • ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
  • 除了一个定长数组外,ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置

ArrayBlockingQueue 与 LinkedBlockingQueue 的区别:

  • ArrayBlockingQueue 在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;

    • 按照实现原理来分析,ArrayBlockingQueue 完全可以采用分离锁,从而实现生产者和消费者操作的完全并行运行。Doug Lea 之所以没这样去做,也许是因为 ArrayBlockingQueue 的数据写入和获取操作已经足够轻巧,以至于引入独立的锁机制,除了给代码带来额外的复杂性外,其在性能上完全占不到任何便宜。
  • ArrayBlockingQueue 和LinkedBlockingQueue 间还有一个明显的不同之处在于,前者在插入或删除元素时不会产生或销毁任何额外的对象实例,而后者则会生成一个额外的Node 对象

    • 这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC 的影响还是存在一定的区别。
  • 在创建 ArrayBlockingQueue 时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁

    以下是在使用 ArrayBlockingQueue 的时候对其初始化的一个示例:

1
2
3
BlockingQueue queue = new ArrayBlockingQueue(1024);
queue.put("1");
Object object = queue.take();

以下是使用了 Java 泛型的一个 BlockingQueue 示例。注意其中是如何对 String 元素放入和提取的:

1
2
3
BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1024);
queue.put("1");
String string = queue.take();

==一句话总结:ArrayBlockingQueue 是由数组结构组成的有界阻塞队列。==

2、延迟队列——DelayQueue

DelayQueue 实现了 BlockingQueue 接口。

DelayQueue 是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。

DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口,该接口定义:

1
2
3
public interface Delayed extends Comparable<Delayed> {
public long getDelay(TimeUnit timeUnit);
}

DelayQueue 将会在每个元素的 getDelay() 方法返回的值的时间段之后才释放掉该元素。如果返回的是 0 或者负值,延迟将被认为过期,该元素将会在 DelayQueue 的下一次 take 被调用的时候被释放掉

传递给 getDelay 方法的 getDelay 实例是一个枚举类型,它表明了将要延迟的时间段。TimeUnit 枚举将会取以下值:

  • DAYS——天
  • HOURS——时
  • INUTES——分钟
  • SECONDS——秒
  • MILLISECONDS——毫秒
  • MICROSECONDS——微秒
  • NANOSECONDS——纳秒

正如你所看到的,Delayed 接口也继承了 java.lang.Comparable 接口,这也就意味着 Delayed 对象之间可以进行对比。这个可能在对 DelayQueue 队列中的元素进行排序时有用,因此它们可以根据过期时间进行有序释放。 以下是使用 DelayQueue 的例子:

1
2
3
4
5
6
7
8
public class DelayQueueExample {
public static void main(String[] args) {
DelayQueue queue = new DelayQueue();
Delayed element1 = new DelayedElement();
queue.put(element1);
Delayed element2 = queue.take();
}
}

DelayedElement 是我所创建的一个 DelayedElement 接口的实现类,它不在 java.util.concurrent 包里。你需要自行创建你自己的 Delayed 接口的实现以使用 DelayQueue 类。

==一句话总结:使用优先级队列实现的延迟无界阻塞队列。==

**3、链阻塞队列——LinkedBlockingQueue**(常用)

LinkedBlockingQueue 类实现了 BlockingQueue 接口。

LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。

LinkedBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。

LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

ArrayBlockingQueue 和 LinkedBlockingQueue 是两个最普通也是最常用的阻塞队列,一般情况下,在处理多线程间的生产者消费者问题,使用这两个类足以。

以下是 LinkedBlockingQueue 的初始化和使用示例代码:

1
2
3
4
BlockingQueue<String> unbounded = new LinkedBlockingQueue<String>();
BlockingQueue<String> bounded = new LinkedBlockingQueue<String>(1024);
bounded.put("Value");
String value = bounded.take();

==一句话总结:由链表结构组成的有界(但大小默认值为integer.MAX_VALUE)阻塞队列。==

4、具有优先级的阻塞队列——PriorityBlockingQueue

PriorityBlockingQueue 类实现了 BlockingQueue 接口。

PriorityBlockingQueue 是一个基于优先级的无界的并发队列。(优先级的判断通过构造函数传入的 Compator 对象来决定)

它使用了和类 java.util.PriorityQueue 一样的排序规则。

  • 你无法向这个队列中插入 null 值。
  • 所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。

注意:

  • PriorityBlockingQueue 对于具有相等优先级(compare() == 0)的元素并不强制任何特定行为。
  • 如果你从一个 PriorityBlockingQueue 获得一个 Iterator 的话,该 Iterator 并不能保证它对元素的遍历是以优先级为序的。
  • 由于PriorityBlockingQueue是无界的,所以PriorityBlockingQueue 并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者
    • 因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。
  • 在实现 PriorityBlockingQueue 时,内部控制线程同步的锁采用的是==公平锁==。

以下是使用 PriorityBlockingQueue 的示例:

1
2
3
4
BlockingQueue queue   = new PriorityBlockingQueue();
//String implements java.lang.Comparable
queue.put("Value");
String value = queue.take();

==一句话总结:支持优先级排序的无界阻塞队列。==

5、同步队列——SynchronousQueue

SynchronousQueue 类实现了 BlockingQueue 接口。

SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。 据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。

声明一个 SynchronousQueue 有两种不同的方式——公平模式非公平模式,它们之间有着不太一样的行为:

  • 公平模式:SynchronousQueue 会采用公平锁,并配合一个 FIFO 队列来阻塞多余的生产者和消费者,从而体系整体的公平策略;
  • 非公平模式(SynchronousQueue 默认):SynchronousQueue 采用非公平锁,同时配合一个 LIFO 队列来管理多余的生产者和消费者,而后一种模式,如果生产者和消费者的处理速度有差距,则很容易出现饥渴的情况,即可能有某些生产者或者是消费者的数据永远都得不到处理。

==一句话总结:不存储元素的阻塞队列,也即单个元素的队列。==

6、链阻塞无界队列——LinkedTransferQueue

LinkedTransferQueue 是一个由链表结构组成的无界阻塞 TransferQueue 队列。相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和transfer 方法。

LinkedTransferQueue 采用一种预占模式。意思就是消费者线程取元素时,如果队列不为空,则直接取走数据,若队列为空,那就生成一个节点(节点元素为 null)入队,然后消费者线程被等待在这个节点上,后面生产者线程入队时发现有一个元素为 null 的节点,生产者线程就不入队了,直接就将元素填充到该节点,并唤醒该节点等待的线程,被唤醒的消费者线程取走元素,从调用的方法返回。

==一句话总结:由链表组成的无界阻塞队列。==

4、BlockingDeque 的例子

既然 BlockingDeque 是一个接口,那么你想要使用它的话就得使用它的众多的实现类的其中一个。java.util.concurrent 包提供了以下 BlockingDeque 接口的实现类:LinkedBlockingDeque

以下是如何使用 BlockingDeque 方法的一个简短代码示例:

1
2
3
4
5
6
BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
deque.addFirst("1");
deque.addLast("2");

String two = deque.takeLast();
String one = deque.takeFirst();
链阻塞双端队列——LinkedBlockDeque

LinkedBlockingDeque 类实现了 BlockingDeque 接口。

LinkedBlockingDeque 是一个由链表结构组成的双向阻塞队列,即可以从队列的两端插入和移除元素。

deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。

LinkedBlockingDeque 是一个双端队列,在它为空的时候,一个试图从中抽取数据的线程将会阻塞,无论该线程是试图从哪一端抽取数据。

对于一些指定的操作,在插入或者获取队列元素时如果队列状态不允许该操作可能会阻塞住,该线程直到队列状态变更为允许操作,这里的阻塞一般有两种情况:

  • 插入元素时:如果当前队列已满将会进入阻塞状态,一直等到队列有空的位置时再讲该元素插入,该操作可以通过设置超时参数,超时后返回 false 表示操作失败,也可以不设置超时参数一直阻塞,中断后抛出 InterruptedException 异常
  • 读取元素时:如果当前队列为空会阻塞住直到队列不为空然后返回元素,同样可以通过设置超时参数

以下是 LinkedBlockingDeque 实例化以及使用的示例:

1
2
3
4
5
6
BlockingDeque<String> deque = new LinkedBlockingDeque<String>();
deque.addFirst("1");
deque.addLast("2");

String two = deque.takeLast();
String one = deque.takeFirst();

==一句话总结:由链表组成的双向阻塞队列==

11、JUC集合:LinkedBlockingQueue详解

1、LinkedBlockingQueue 原理

1、基本的入队出队
1
2
3
4
5
6
7
8
9
10
11
12
13
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
static class Node<E> {
E item;
/**
* 下列三种情况之一
* - 真正的后继节点
* - 自己, 发生在出队时
* - null, 表示是没有后继节点, 是最后了*/
Node<E> next;
Node(E x) { item = x; }
}
}

初始化链表 last = head = new Node<E>(null); Dummy 节点用来占位,item 为 null

image-20210814034851138

当一个节点入队 last = last.next = node;

image-20210814034913187

再来一个节点入队 last = last.next = node;

image-20210814034931731

出队

1
2
3
4
5
6
Node<E> h = head; 
Node<E> first = h.next;
h.next = h; // help GC head = first;
E x = first.item;
first.item = null;
return x;

h = head

image-20210814035037609

first = h.next

image-20210814035055942

h.next = h

image-20210814035113682

head = first

image-20210814035130035

1
2
3
E x = first.item; 
first.item = null;
return x;

image-20210814035147199

2、加锁分析

==高明之处==在于用了两把锁和 dummy 节点

  • 用一把锁,同一时刻,最多只允许有一个线程(生产者或消费者,二选一)执行
  • 用两把锁,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行(锁住的队列的头和尾)
    • 消费者与消费者线程仍然串行
    • 生产者与生产者线程仍然串行

线程安全分析

  • 当节点总数大于 2 时(包括 dummy 节点),putLock 保证的是 last 节点的线程安全,takeLock 保证的是 head 节点的线程安全。两把锁保证了入队和出队没有竞争
  • 当节点总数等于 2 时(即一个 dummy 节点,一个正常节点)这时候,仍然是两把锁锁两个对象,不会竞争
    • 这里就体现了dummy 占位节点的用处了:就算只剩下一个正常的结点,两把锁锁住的依旧是两个对象,没有竞争
  • 当节点总数等于 1 时(就一个 dummy 节点)这时 take 线程会被 notEmpty 条件阻塞,有竞争,会阻塞
1
2
3
4
// 用于 put(阻塞) offer(非阻塞)
private final ReentrantLock putLock = new ReentrantLock();
// 用户 take(阻塞) poll(非阻塞)
private final ReentrantLock takeLock = new ReentrantLock();

put 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// count 用来维护元素计数
final AtomicInteger count = this.count; putLock.lockInterruptibly();
try {
// 满了等待
while (count.get() == capacity) {
// 倒过来读就好: 等待 notFull
notFull.await();
}
// 有空位, 入队且计数加一
enqueue(node);
c = count.getAndIncrement();
// 除了自己 put 以外, 队列还有空位, 由自己叫醒其他 put 线程
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
// 如果队列中有一个元素, 叫醒 take 线程
if (c == 0)
// 这里调用的是 notEmpty.signal() 而不是 notEmpty.signalAll() 是为了减少竞争
signalNotEmpty();
}

take 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {
notEmpty.await();
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 如果队列中只有一个空位时, 叫醒 put 线程
// 如果有多个线程进行出队, 第一个线程满足 c == capacity, 但后续线程 c < capacity
if (c == capacity)
// 这里调用的是 notFull.signal() 而不是 notFull.signalAll() 是为了减少竞争
signalNotFull();
return x;
}

由 put 唤醒 put 是为了避免信号不足

2、性能比较

主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较

  • Linked 支持有界,Array 强制有界
  • Linked 实现是链表,Array 实现是数组
  • Linked 是懒惰的,而 Array 需要提前初始化 Node 数组
  • Linked 每次入队会生成新 Node,而 Array 的 Node 是提前创建好的
  • Linked 两把锁,Array 一把锁

12、JUC集合:ConcurrentLinkedQueue详解

  • 一个基于链接节点的无界线程安全队列。此队列按照 ==FIFO(先进先出)原==则对元素进行排序。
  • 队列的头部是队列中时间最长的元素。队列的尾部是队列中时间最短的元素
  • 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素
  • 多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。
  • 此队列不允许使用 null 元素

ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是

  • 两把【锁】,同一时刻,可以允许两个线程同时(一个生产者与一个消费者)执行
  • dummy 节点的引入让两把【锁】将来锁住的是不同对象,避免竞争
  • 只是这【锁】使用了 cas 来实现

事实上,ConcurrentLinkedQueue 应用还是非常广泛的

例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用

image-20210814043528344

1、BAT大厂的面试问题

  • 要想用线程安全的队列有哪些选择?
    • Vector,Collections.synchronizedList(List<T> list), ConcurrentLinkedQueue等
  • ConcurrentLinkedQueue实现的数据结构?
  • ConcurrentLinkedQueue底层原理?
    • 全程无锁(CAS)
  • ConcurrentLinkedQueue的核心方法有哪些?
    • offer(),poll(),peek(),isEmpty()等队列常用方法
  • 说说ConcurrentLinkedQueue的HOPS(延迟更新的策略)的设计?
  • ConcurrentLinkedQueue适合什么样的使用场景?

2、ConcurrentLinkedQueue数据结构

通过源码分析可知,ConcurrentLinkedQueue的数据结构与LinkedBlockingQueue的数据结构相同,都是使用的链表结构。

ConcurrentLinkedQueue的数据结构如下:

img

说明:ConcurrentLinkedQueue采用的链表结构,并且包含有一个头结点和一个尾结点。

3、ConcurrentLinkedQueue源码分析

1、类的继承关系
1
2
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {}

说明:ConcurrentLinkedQueue继承了抽象类AbstractQueue,AbstractQueue定义了对队列的基本操作;同时实现了Queue接口,Queue定义了对队列的基本操作,同时,还实现了Serializable接口,表示可以被序列化。

2、类的内部类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
private static class Node<E> {
// 元素
volatile E item;
// next域
volatile Node<E> next;

/**
* Constructs a new node. Uses relaxed write because item can
* only be seen after publication via casNext.
*/
// 构造函数
Node(E item) {
// 设置item的值
UNSAFE.putObject(this, itemOffset, item);
}
// 比较并替换item值
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}

void lazySetNext(Node<E> val) {
// 设置next域的值,并不会保证修改对其他线程立即可见
UNSAFE.putOrderedObject(this, nextOffset, val);
}
// 比较并替换next域的值
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}

// Unsafe mechanics
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// item域的偏移量
private static final long itemOffset;
// next域的偏移量
private static final long nextOffset;

static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = Node.class;
itemOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("item"));
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}

说明:Node类表示链表结点,用于存放元素,包含item域和next域,item域表示元素,next域表示下一个结点,**其利用反射机制和CAS机制来更新item域和next域,==保证原子性==**。

3、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 196745693267521676L;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// head域的偏移量
private static final long headOffset;
// tail域的偏移量
private static final long tailOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = ConcurrentLinkedQueue.class;
headOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("head"));
tailOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("tail"));
} catch (Exception e) {
throw new Error(e);
}
}

// 头结点
private transient volatile Node<E> head;
// 尾结点
private transient volatile Node<E> tail;
}

说明:属性中包含了head域和tail域,表示链表的头结点和尾结点,同时,ConcurrentLinkedQueue也使用了反射机制和CAS机制来更新头结点和尾结点,==保证原子==性

4、 类的构造函数
  • ConcurrentLinkedQueue()型构造函数

    • public ConcurrentLinkedQueue() {
          // 初始化头结点与尾结点
          head = tail = new Node<E>(null);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31

      - 说明:**该构造函数用于创建一个最初为空的 ConcurrentLinkedQueue,头结点与尾结点指向同一个结点,该结点的item域为null,next域也为null**。

      - `ConcurrentLinkedQueue(Collection<? extends E>)`型构造函数

      - ```java
      public ConcurrentLinkedQueue(Collection<? extends E> c) {
      Node<E> h = null, t = null;
      for (E e : c) { // 遍历c集合
      // 保证元素不为空
      checkNotNull(e);
      // 新生一个结点
      Node<E> newNode = new Node<E>(e);
      if (h == null) // 头结点为null
      // 赋值头结点与尾结点
      h = t = newNode;
      else {
      // 直接头结点的next域
      t.lazySetNext(newNode);
      // 重新赋值头结点
      t = newNode;
      }
      }
      if (h == null) // 头结点为null
      // 新生头结点与尾结点
      h = t = new Node<E>(null);
      // 赋值头结点
      head = h;
      // 赋值尾结点
      tail = t;
      }
    • 说明:该构造函数用于创建一个最初包含给定 collection 元素的 ConcurrentLinkedQueue,按照此 collection 迭代器的遍历顺序来添加元素

5、核心函数分析
1、offer函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public boolean offer(E e) {
// 元素不为null
checkNotNull(e);
// 新生一个结点
final Node<E> newNode = new Node<E>(e);

for (Node<E> t = tail, p = t;;) { // 无限循环
// q为p结点的下一个结点
Node<E> q = p.next;
if (q == null) { // q结点为null
// p is last node
if (p.casNext(null, newNode)) { // 比较并进行替换p结点的next域
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // p不等于t结点,不一致 // hop two nodes at a time
// 比较并替换尾结点
casTail(t, newNode); // Failure is OK.
// 返回
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q) // p结点等于q结点
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
// 原来的尾结点与现在的尾结点是否相等,若相等,则p赋值为head,否则,赋值为现在的尾结点
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
// 重新赋值p结点
p = (p != t && t != (t = tail)) ? t : q;
}
}

说明:offer函数用于将指定元素插入此队列的尾部。下面模拟offer函数的操作,队列状态的变化(假设单线程添加元素,连续添加10、20两个元素)。

img

  • 若ConcurrentLinkedQueue的初始状态如上图所示,即队列为空。单线程添加元素,此时,添加元素10,则状态如下所示:
    • img
  • 如上图所示,添加元素10后,tail没有变化,还是指向之前的结点,继续添加元素20,则状态如下所示:
    • img
  • 如上图所示,添加元素20后,tail指向了最新添加的结点。
2、poll函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public E poll() {
restartFromHead:
for (;;) { // 无限循环
for (Node<E> h = head, p = h, q;;) { // 保存头结点
// item项
E item = p.item;

if (item != null && p.casItem(item, null)) { // item不为null并且比较并替换item成功
// Successful CAS is the linearization point
// for item to be removed from this queue.
if (p != h) // p不等于h // hop two nodes at a time
// 更新头结点
updateHead(h, ((q = p.next) != null) ? q : p);
// 返回item
return item;
}
else if ((q = p.next) == null) { // q结点为null
// 更新头结点
updateHead(h, p);
return null;
}
else if (p == q) // p等于q
// 继续循环
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}

说明:此函数用于获取并移除此队列的头,如果此队列为空,则返回null。下面模拟poll函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,poll两次)。

img

  • 队列初始状态如上图所示,在poll操作后,队列的状态如下图所示:
    • img
  • 如上图可知,poll操作后,head改变了,并且head所指向的结点的item变为了null。再进行一次poll操作,队列的状态如下图所示:
    • img
  • 如上图可知,poll操作后,head结点没有变化,只是指示的结点的item域变成了null。
3、remove函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean remove(Object o) {
// 元素为null,返回
if (o == null) return false;
Node<E> pred = null;
for (Node<E> p = first(); p != null; p = succ(p)) { // 获取第一个存活的结点
// 第一个存活结点的item值
E item = p.item;
if (item != null &&
o.equals(item) &&
p.casItem(item, null)) { // 找到item相等的结点,并且将该结点的item设置为null
// p的后继结点
Node<E> next = succ(p);
if (pred != null && next != null) // pred不为null并且next不为null
// 比较并替换next域
pred.casNext(p, next);
return true;
}
// pred赋值为p
pred = p;
}
return false;
}

说明:**此函数用于从队列中移除指定元素的单个实例(如果存在)**。其中,会调用到first函数和succ函数,first函数的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Node<E> first() {
restartFromHead:
for (;;) { // 无限循环,确保成功
for (Node<E> h = head, p = h, q;;) {
// p结点的item域是否为null
boolean hasItem = (p.item != null);
if (hasItem || (q = p.next) == null) { // item不为null或者next域为null
// 更新头结点
updateHead(h, p);
// 返回结点
return hasItem ? p : null;
}
else if (p == q) // p等于q
// 继续从头结点开始
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}

说明:first函数用于找到链表中第一个存活的结点。succ函数源码如下:

1
2
3
4
5
6
final Node<E> succ(Node<E> p) {
// p结点的next域
Node<E> next = p.next;
// 如果next域为自身,则返回头结点,否则,返回next
return (p == next) ? head : next;
}

说明:succ用于获取结点的下一个结点。如果结点的next域指向自身,则返回head头结点,否则,返回next结点。

下面模拟remove函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,执行remove(10)、remove(20)操作)。

img

  • 如上图所示,为ConcurrentLinkedQueue的初始状态,remove(10)后的状态如下图所示:
    • img
  • 如上图所示,当执行remove(10)后,head指向了head结点之前指向的结点的下一个结点,并且head结点的item域置为null。继续执行remove(20),状态如下图所示:
    • img
  • 如上图所示,执行remove(20)后,head与tail指向同一个结点,item域为null。
4、size函数
1
2
3
4
5
6
7
8
9
10
11
public int size() {
// 计数
int count = 0;
for (Node<E> p = first(); p != null; p = succ(p)) // 从第一个存活的结点开始往后遍历
if (p.item != null) // 结点的item域不为null
// Collection.size() spec says to max out
if (++count == Integer.MAX_VALUE) // 增加计数,若达到最大值,则跳出循环
break;
// 返回大小
return count;
}

说明:此函数用于返回ConcurrenLinkedQueue的大小,从第一个存活的结点(first)开始,往后遍历链表,当结点的item域不为null时,增加计数,之后返回大小

4、ConcurrentLinkedQueue示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.util.concurrent.ConcurrentLinkedQueue;

class PutThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public PutThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}

@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("add " + i);
clq.add(i);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

class GetThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public GetThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}

@Override
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("poll " + clq.poll());
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

public class ConcurrentLinkedQueueDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue<Integer> clq = new ConcurrentLinkedQueue<Integer>();
PutThread p1 = new PutThread(clq);
GetThread g1 = new GetThread(clq);

p1.start();
g1.start();

}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
add 0
poll null
add 1
poll 0
add 2
poll 1
add 3
poll 2
add 4
poll 3
add 5
poll 4
poll 5
add 6
add 7
poll 6
poll 7
add 8
add 9
poll 8

说明:GetThread线程不会因为ConcurrentLinkedQueue队列为空而等待,而是直接返回null,所以当实现队列不空时,等待时,则需要用户自己实现等待逻辑

5、再深入理解

1、HOPS(延迟更新的策略)的设计

通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:

  • tail更新触发时机:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。
  • head更新触发时机:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。

并且在更新操作时,源码中会有注释为:hop two nodes at a time。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个(猜的 😃),从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢?

如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。

2、ConcurrentLinkedQueue适合的场景

ConcurrentLinkedQueue通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考

13、多线程锁

1、公平锁与非公平锁

1、公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
2、非公平锁

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
3、公平锁与非公平锁

公平和非公平都是排序队列的,但是公平的新创建的线程会排到所有的就绪队列之后,非公平的线程会和就绪队列直接竞争资源,也就是插队。

举个去KFC吃饭的例子(来自敖丙dalao的例子)

  • 现在是早餐时间,敖丙想去kfc搞个早餐,发现有很多人了,一过去没多想,就乖乖到队尾排队,这样大家都觉得很公平,先到先得,所以这是公平锁咯。
    • img
  • 那非公平锁就是,敖丙过去买早餐,发现大家都在排队,但是敖丙这个人有点渣的,就是喜欢插队,那他就直接怼到第一位那去,后面的鸡蛋,米豆都不行,我插队也不敢说什么,只能默默忍受了。
    • img
  • 但是偶尔,鸡蛋也会崛起,叫我滚到后面排队,我也是欺软怕硬,默默到后面排队,就插队失败了。
    • img
4、公平锁与非公平锁的实现——ReentrantLock(具体看一看上文的ReentrantLock)

在上文中介绍了ReentrantLock类,以及ReentrantLock类的三个内部类——SyncNonfairSyncFairSync

其中NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。

而NonfairSync实现的就是非公平锁(ReentrantLock的默认实现),FairSync实现的就是公平锁。

公平锁:(FairSync源码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
// 尝试公平获取锁
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (c == 0) { // 状态为0
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
// 设置当前线程独占
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
// 下一个状态
int nextc = c + acquires;
if (nextc < 0) // 超过了int的表示范围
throw new Error("Maximum lock count exceeded");
// 设置状态
setState(nextc);
return true;
}
return false;
}

仔细看FairSync的源码就能发现,它加了一个hasQueuedPredecessors的判断,那他判断里面有些什么玩意呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
public final boolean  hasQueuedPredecessors() {
// The correctness of this depends on head being initia Lized
// before tail and on head.next being accurate if the current
// thread is first in queue.
//其实这个赋值顺序也是很有讲究的,倒过来有可能会导致空指针
Node t = tail; // Read fields in reverse initia Lization orde r
Nodeh = head;
Node s;
return h != t && // h != t 时表示队列中有 Node
// (s = h.next) == null 表示队列中还有没有老二
// 或者队列中老二线程不是此线程
((s = h.next) == null | s. thread != Thread. currentThread());
}

代码的大概意思也是判断当前的线程是不是位于同步队列的首位,是就是返回true,否就返回false。

非公平锁:(NonfairSync源码)

1
2
3
4
5
6
7
8
9
// 获得锁
final void lock() {
if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
// 把当前线程设置独占了锁
setExclusiveOwnerThread(Thread.currentThread());
else // 锁已经被占用,或者set失败
// 以独占模式获取对象,忽略中断
acquire(1);
}

从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。

5、公平锁与非公平锁的实现过程

非公平锁:

  • A线程准备进去获取锁,首先判断了一下state状态,发现是0,所以可以CAS成功,并且修改了当前持有锁的线程为自己。
    • img
  • 这个时候B线程也过来了,也是一上来先去判断了一下state状态,发现是1,那就CAS失败了,真晦气,只能乖乖去等待队列,等着唤醒了,先去睡一觉吧。
    • img
  • A持有久了,也有点腻了,准备释放掉锁,给别的仔一个机会,所以改了state状态,抹掉了持有锁线程的痕迹,准备去叫醒B。
    • img
  • 这个时候有个带绿帽子的仔C过来了,发现state怎么是0啊,果断CAS修改为1,还修改了当前持有锁的线程为自己。
  • B线程被A叫醒准备去获取锁,发现state居然是1,CAS就失败了,只能失落的继续回去等待队列,路线还不忘骂A渣男,怎么骗自己,欺骗我的感情。
    • img

以上就是一个非公平锁的线程,这样的情况就有可能像B这样的线程长时间无法得到资源,优点就是可能有的线程减少了等待时间,提高了利用率。

公平锁:

  • 线A现在想要获得锁,先去判断下state,发现也是0,去看了看队列,自己居然是第一位,果断修改了持有线程为自己。
    • img
  • 线程B过来了,去判断一下state,嗯哼?居然是state=1,那cas就失败了呀,所以只能乖乖去排队了。
    • img
  • 线程A暖男来了,持有没多久就释放了,改掉了所有的状态就去唤醒线程B了,这个时候线程C进来了,但是他先判断了下state发现是0,以为有戏,然后去看了看队列,发现前面有人了,作为新时代的良好市民,果断排队去了。
    • img
  • 线程B得到A的召唤,去判断state了,发现值为0,自己也是队列的第一位,那很香呀,可以得到了。
    • img

以上就是一个公平锁的线程,这样的情况就不会出现线程长时间无法得到资源,缺点就是要判断当前的等待队列是否有线程在等待,花费的开销较大,效率不行。

6、深入:公平锁真的公平吗?

公平锁相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
//重入锁的代码
...
}
return false;
}
1
2
3
4
5
6
7
8
public final boolean hasQueuedPredecessors() {
//其实这个赋值顺序也是很有讲究的,倒过来有可能会导致空指针
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
//初始化
if (compareAndSetHead(new Node()))......①
tail = head;........................②
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}

情景:假设当前有三个线程A、B、C,分别取调用公平锁的lock.lock()

  • 假设线程A一马当先,先获取到锁,此时state == 1。然后线程B,也来到了tryAcquire方法
    • 公平锁与非公平锁的区别就是在tryAcquire中会判断是否有先驱节点,也就是方法hasQueuedPredecessors
  • 此时tailheadnull,所以肯定方法hasQueuedPredecessors返回false
  • 线程B回到tryAcquire中执行cas_state方法,由于A还没有释放锁,所以肯定获取不到,最终返回false,需要加入同步队列。在addWaiter中,由于tail == null 直接进入enq方法。
  • ①和②便是重点。
情景1:
  • 当线程B执行到①,此时head有值,但是tail还是为null

  • 此时线程C也执行到hasQueuedPredecessors

  • Node t = null;
    Node h = new Node();
    此时 h != t && ((s = h.next) == null)  为true
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    因此线程`C`不能插队,也要加入等待队列。

    ###### 情景2:

    - 当线程`B`执行到②,此时`head`有值,且`head == tail`

    - 此时线程`C`也执行到`hasQueuedPredecessors`

    - ```java
    Node t = h
    此时 h != t 为false 短路直接返回

因此线程C可以插队,去执行cas_state方法
假设在执行cas方法之前,线程A已经释放了锁,那么线程C就可以插队,先于B抢到锁。

关于公平锁源码中hasQueuedPredecessors()方法中tail和head赋值顺序问题

如果head先于tail赋值

1
2
3
4
5
6
7
8
public final boolean hasQueuedPredecessors() {
Node h = head; //如果此时head还没有初始化,获得的是null,赋值完后失去时间片
Node t = tail; //此时head完成初始化,且tail != null
Node s;
return h != t && // h != t 成立 没有短路
//h == null 因此h.next会产生NPE
((s = h.next) == null || s.thread != Thread.currentThread());
}
总结

ReentrantLock中的公平锁只有在等待队列中存在等待节点(不包括虚节点)的时候,才是真正意义上的公平锁。

2、可重入锁

1、什么是重入锁

通常情况下,锁可以用来控制多线程的访问行为。那对于同一个线程,如果连续两次对同一把锁进行lock,会怎么样了?

对于一般的锁来说,这个线程就会被永远卡死在那边,比如:

1
2
3
4
5
6
void handle() {
lock();
lock(); //和上一个lock()操作同一个锁对象,那么这里就永远等待了
unlock();
unlock();
}

这个特性相当不好用,因为在实际的开发过程中,函数之间的调用关系可能错综复杂,一个不小心就可能在多个不同的函数中,反复调用lock(),这样的话,线程就自己和自己卡死了。

所以,对于希望傻瓜式编程的我们来说,重入锁就是用来解决这个问题的。重入锁使得同一个线程可以对同一把锁,在不释放的前提下,反复加锁,而不会导致线程卡死。因此,如果我们使用的是重入锁,那么上述代码就可以正常工作。你唯一需要保证的,就是unlock()的次数和lock()一样多(否则会造成死锁)。

image-20210723031848803

2、重入锁的实现原理

java当中的重入锁——Lock接口的实现类ReentrantLock。其中最重要的方法——lock()

重入锁内部实现的主要类如下图:

图片

重入锁的核心功能委托给内部类Sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。

实现重入锁的方法很简单,就是基于一个状态变量state。这个变量保存在AbstractQueuedSynchronizer(AQS)对象中

1
private volatile int state;

当这个state==0时,表示锁是空闲的,大于零表示锁已经被占用, 它的数值表示当前线程重复占用这个锁的次数。因此,lock()的最简单的实现是:

1
2
3
4
5
6
7
8
final void lock() {
// compareAndSetState就是对state进行CAS操作,如果修改成功就占用锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//如果修改不成功,说明别的线程已经使用了这个锁,那么就可能需要等待
acquire(1);
}

下面是acquire() 的实现:

1
2
3
4
5
6
7
8
9
10
 public final void acquire(int arg) {
//tryAcquire() 再次尝试获取锁,
//如果发现锁就是当前线程占用的,则更新state,表示重复占用的次数,
//同时宣布获得锁成功,这正是重入的关键所在
if (!tryAcquire(arg) &&
// 如果获取失败,那么就在这里入队等待
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//如果在等待过程中 被中断了,那么重新把中断标志位设置上
selfInterrupt();
}
3、公平的重入锁与非公平的重入锁

默认情况下,重入锁是不公平的。

那公平锁和非公平锁实现的核心区别在哪里呢?

  • 对于lock()方法代码:

    • //非公平锁 
       final void lock() {
           //上来不管三七二十一,直接抢了再说
           if (compareAndSetState(0, 1))
               setExclusiveOwnerThread(Thread.currentThread());
           else
               //抢不到,就进队列慢慢等着
               acquire(1);
       }
      
       //公平锁
       final void lock() {
           //直接进队列等着
           acquire(1);
       }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52

      - 从上面的代码中也不难看到,非公平锁如果第一次争抢失败,后面的处理和公平锁是一样的,都是进入等待队列慢慢等。

      - 对于tryLock()方法代码:

      - ```java
      //非公平锁
      final boolean nonfairTryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
      //上来不管三七二十一,直接抢了再说
      if (compareAndSetState(0, acquires)) {
      setExclusiveOwnerThread(current);
      return true;
      }
      }
      //如果就是当前线程占用了锁,那么就更新一下state,表示重复占用锁的次数
      //这是“重入”的关键所在
      else if (current == getExclusiveOwnerThread()) {
      //我又来了哦~~~(重入)
      int nextc = c + acquires;
      if (nextc < 0) // overflow
      throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
      }
      return false;
      }


      //公平锁
      protected final boolean tryAcquire(int acquires) {
      final Thread current = Thread.currentThread();
      int c = getState();
      if (c == 0) {
      //先看看有没有别人在等,没有人等我才会去抢,有人在我前面 ,我就不抢啦
      if (!hasQueuedPredecessors() &&
      compareAndSetState(0, acquires)) {
      setExclusiveOwnerThread(current);
      return true;
      }
      }
      else if (current == getExclusiveOwnerThread()) {
      int nextc = c + acquires;
      if (nextc < 0)
      throw new Error("Maximum lock count exceeded");
      setState(nextc);
      return true;
      }
      return false;
      }
4、Condition

Condition可以理解为重入锁的伴生对象。它提供了在重入锁的基础上,进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象,如下所示:

1
2
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();

那Condition对象怎么用呢?在JDK内部就有一个很好的例子。让我们来看一下ArrayBlockingQueue吧。

ArrayBlockingQueue是一个队列,你可以把元素塞入队列(enqueue),也可以拿出来take()。但是有一个小小的条件,就是如果队列是空的,那么take()就需要等待,一直等到有元素了,再返回。

那这个功能,怎么实现呢?这就可以使用Condition对象了。

实际在ArrayBlockingQueue中,就维护一个Condition对象:

1
2
lock = new ReentrantLock(true);
notEmpty = lock.newCondition();

这个notEmpty 就是一个Condition对象。它用来通知其他线程,ArrayBlockingQueue是不是空着的。当我们需要拿出一个元素时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
// 如果队列长度为0,那么就在notEmpty condition上等待了,一直等到有元素进来为止
// 注意,await()方法,一定是要先获得condition伴生的那个lock,才可以使用。
notEmpty.await();
//一旦有人通知我队列里有东西了,我就弹出一个返回
return dequeue();
} finally {
lock.unlock();
}
}

当有元素入队时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
//先拿到锁,拿到锁才能操作对应的Condition对象
lock.lock();
try {
if (count == items.length)
return false;
else {
//入队了, 在这个函数里,就会进行notEmpty的通知,通知相关线程,有数据准备好了
enqueue(e);
return true;
}
} finally {
//释放锁了,等着的那个线程,现在可以去弹出一个元素试试了
lock.unlock();
}
}

private void enqueue(E x) {
final Object[] items = this.items;
items[putIndex] = x;
if (++putIndex == items.length)
putIndex = 0;
count++;
//元素已经放好了,通知那个等着拿东西的人吧
notEmpty.signal();
}

因此,整个流程如图所示:

图片

5、显示重入锁(lock)与隐式重入锁(Synchronized
  • 显示重入锁(lock):需要手动的上锁与释放锁的重入锁
    • 如上文所说,lock的ReentrantLock就是显示重入锁
  • 隐式重入锁(Synchronized):自动的上锁与释放锁的重入锁
    • Synchronized的上锁与释放锁是由JVM自动控制的
6、重入锁的使用示例

使用重入锁,实现一个简单的计数器。这个计数器可以保证在多线程环境中,统计数据的精确性,请看下面示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Counter {
//重入锁
private final Lock lock = new ReentrantLock();
private int count;
public void incr() {
// 访问count时,需要加锁
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}

public int getCount() {
//读取数据也需要加锁,才能保证数据的可见性
lock.lock();
try {
return count;
}finally {
lock.unlock();
}
}
}
7、可重入锁总结
  • 显示重入锁(lock)与隐式重入锁(Synchronized
  • 对于同一个线程,重入锁允许你反复获得通一把锁,但是,申请和释放锁的次数必须一致。
  • 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
  • 重入锁的内部实现是基于CAS操作的
  • 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信
  • 如果是不可重入锁的话,第一个锁没有解锁就不能操作第二个锁的内容

3、死锁

1、什么是死锁

两个或多个进程在运行过程中,因争夺资源而造成的一种相互等待的现象,当进程处于这种相互等待的状态时,若无外力作用,它们都将无法再向前推进。

image-20210723034133100

2、产生死锁的三大原因
  1. 竞争可消耗资源
  2. 竞争不可抢占资源
    • 系统中的资源可以分为两类:
      • 可剥夺资源:是指某进程在获得这类资源后,该资源可以再被其他进程或系统剥夺,CPU和主存均属于可剥夺性资源;
      • 另一类资源是不可剥夺资源,当系统把这类资源分配给某进程后,再不能强行收回,只能在进程用完后自行释放,如磁带机、打印机等。
      • 还有一种资源:临时资源。
        • 包括硬件中断、信号、消息、缓冲区内的消息等
        • 它可以是可剥夺资源,也可以是不可剥夺资源
    • 产生死锁中的竞争资源指的是竞争不可剥夺资源的临时资源
  3. 进程运行推进顺序不当
    • 若P1保持了资源R1,P2保持了资源R2,系统处于不安全状态,因为这两个进程再向前推进,便可能发生死锁
    • 当P1运行到P1:Request(R2)时,将因R2已被P2占用而阻塞;当P2运行到P2:Request(R1)时,也将因R1已被P1占用而阻塞,于是发生进程死锁
3、产生死锁的四大条件

产生死锁的必要条件:

  1. 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
  2. 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
  4. 循环等待条件:在发生死锁时,必然存在一个进程–资源的环形链。
4、验证是否发生死锁的方法
  • jps
    • 类似linux的ps -ef
  • jstack
    • jvm自带的堆栈跟踪工具
  • JConsole等工具
5、解决死锁的方法

处理死锁的方法可归结为四种:

  • 预防死锁
  • 避免死锁
  • 检测死锁
  • 解除死锁
1、预防死锁

预防死锁:通过破坏产生死锁的四个必要条件中的一个或几个,以避免发生死锁的方法

  • 破坏“请求和条件”:
    • 必须一次性申请其在整个运行过程中所需的全部资源
      • 优点:简单、易行且安全
      • 缺点:
        • 资源被严重浪费,严重地恶化资源的利用率
        • 使进程经常会发生饥饿现象
    • 对上面方法的改进:允许一个进程只获得运行初期所需的资源后,便开始运行。进程运行过程中再逐步释放已分配给自己的、且已用完毕的全部资源,然后再请求新的所需资源。
  • 破坏“不可抢占条件”:
    • 当一个已经保存了某些不可抢占资源的进程,提出新的资源请求而不能满足时,它必须释放已经保持的所有资源,待以后需要时在重新申请。(这个方法代价太大,一般不使用这个方法)
  • 破坏“循环等待条件”:
    • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反

对于java来说:

  1. 以确定的顺序获得锁
    • 如果必须获取多个锁,那么在设计的时候需要充分考虑不同线程之前获得锁的顺序。按照上面的例子,两个线程获得锁的时序图如下:
      • img
    • 如果此时把获得锁的时序改成:
      • img
    • 那么死锁就永远不会发生。 针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。
    • 问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“循环等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生。
  2. 超时放弃(Lock接口中的tryLock(long time, TimeUnit unit)使用的就是这个方法)
    • 当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,
    • 然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。 还是按照之前的例子,时序图如下:
      • img
2、避免死锁

预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得较满意的系统性能。

由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。

其中最具有代表性的避免死锁算法是银行家算法

银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。

安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。

处在安全状态的进程==一定==不会发生死锁问题,不处在安全状态的进程==可能==发生死锁问题

3、检测死锁
  1. 首先为每个进程和每个资源指定一个唯一的号码;
  2. 然后建立资源分配表和进程等待表。
  3. 资源分配图 + 死锁定理
4、解除死锁

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:

  • 剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
  • 撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态消除为止;
    • 所谓代价是指优先级、运行代价、进程的重要性和价值等。
6、死锁代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 演示死锁
*/
public class DeadLock {

//创建两个对象
static Object a = new Object();
static Object b = new Object();

public static void main(String[] args) {
new Thread(()->{
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 持有锁a,试图获取锁b");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 获取锁b");
}
}
},"A").start();

new Thread(()->{
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 持有锁b,试图获取锁a");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 获取锁a");
}
}
},"B").start();
}
}

4、活锁

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import lombok.extern.slf4j.Slf4j;

import static cn.itcast.n2.util.Sleeper.sleep;

@Slf4j(topic = "c.TestLiveLock")
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();

public static void main(String[] args) {
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
}
}

解决方法:使两个线程互相错开运行

5、饥饿

很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束。

下面我讲一下我遇到的一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题:

image-20210806020827445

顺序加锁的解决方案:

image-20210806020910048

6、乐观锁(Optimistic Locking)和悲观锁(Pessimistic Lock)

1、悲观锁(Pessimistic Lock)
  • 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
  • 悲观锁,具有强烈的独占和排他特性。它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度。因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)
  • 之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。悲观锁的实现:
    • 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
    • Java 里面的同步 synchronized 关键字的实现。
  • 悲观锁主要分为共享锁排他锁
    • 共享锁【shared locks】又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
    • 排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
  • 悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁
  • 说明:
    • 悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会。另外还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。

image-20210726192457057

2、乐观锁(Optimistic Locking)
  • 乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量
  • 乐观锁采取了更加宽松的加锁机制。也是为了避免数据库幻读、业务处理时间过长等原因引起数据处理错误的一种机制,但乐观锁不会刻意使用数据库本身的锁机制,而是依据数据本身来保证数据的正确性。乐观锁的实现:
    • CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
    • 版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
  • 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作
  • 说明:
    • 乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。

image-20210726202536158

乐观锁场景:在线文档

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号一致则修改成功,否则提交失败。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

7、表锁和行锁

表锁行锁主要是mysql数据库的悬挂知识,这里简单提一下,知道概念就行。

  • 表锁:当一个线程在给一张表进行数据操作的时候,会将整一张表都锁起来。在它操作完成之前,其他线程不能对这张表的所有数据进行操作。
    • image-20210726205202616
  • 行锁:一个线程在对一张表的某一行数据进行操作的时候,会将那一行数据锁起来,在它操作完成之前,其他线程不能对这一行数据进行操作,但是对原这张表的其他行数据进行操作是允许的。
    • image-20210726205505114

8、读锁与写锁

读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但不能同时存在读写线程,读写互斥,读读共享。

  • 读锁:共享锁。即允许多个线程一起读取某一个资源——“共享读”
    • 读锁存在”死锁”问题:线程1和线程2一起读取一张表,这时候线程1如果想要修改(写)这张表的数据,就需要线程2完成读操作后退出;同理,这个时候如果线程2也想修改(写)这张表的数据,那么也要等待线程1读取完后退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
    • 注意:如果读锁只读不写的话,就不存在”死锁”问题。
    • image-20210726210610999
  • 写锁:独占锁。即一次只允许一个线程对某一个资源进行写操作(这个时候不存在读线程,也不存在写线程(除非是它自己))——“单独写”
    • 写锁存在也”死锁”问题:线程1和线程2对一张表的不同行进行写操作。这个时候线程1想要写线程2操作的行,就需要等待线程2写完毕退出;同理,线程2想要写线程1操作的行,就需要等待线程1写完毕退出。这个时候就会出现线程1和线程2互相等待的情况——“死锁”
    • image-20210726210629126

9、自旋锁与自适应自旋锁、偏向锁

4、关键字:synchronized篇里有详细说明。这里不在赘述。

14、JUC线程池——FutureTask(未来任务)

1、BAT大厂的面试问题

  • FutureTask用来解决什么问题的?为什么会出现?
  • FutureTask类结构关系怎么样的?
  • FutureTask的线程安全是由什么保证的?
  • FutureTask结果返回机制?
  • FutureTask内部运行状态的转变?
  • FutureTask通常会怎么用?举例说明。

2、FutureTask简介

  • FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)**和取消任务(cancel)**等。
  • 如果任务尚未完成,获取任务执行结果时将会阻塞
  • **一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)**。
  • FutureTask 常用来封装 CallableRunnable,也可以作为一个任务提交到线程池中执行
  • 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用
  • FutureTask 的线程安全由CAS来保证

3、FutureTask类关系

img

可以看到,FutureTask实现了RunnableFuture接口,则RunnableFuture接口继承了Runnable接口和Future接口,所以FutureTask既能当做一个Runnable直接被Thread执行,也能作为Future用来得到Callable的计算结果

4、FutureTask源码分析

1、Callable接口

Callable是个泛型接口,泛型V就是要call()方法返回的类型。若是不能返回成功,则抛异常。

对比Runnable接口,Runnable不会返回数据也不能抛出异常。

1
2
3
4
5
6
7
8
9
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
2、Future接口

Future接口代表异步计算的结果通过Future接口提供的方法可以查看异步计算是否执行完成,或者等待执行结果并获取执行结果,同时还可以取消执行

Future接口的定义如下:

1
2
3
4
5
6
7
8
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
  • cancel(boolean mayInterruptIfRunning)cancel()方法用来取消异步任务的执行。
    • 如果异步任务已经完成或者已经被取消,或者由于某些原因不能取消,则会返回false。
    • 如果任务还没有被执行,则会返回true并且异步任务不会被执行。
    • 如果任务已经开始执行了但是还没有执行完成,若mayInterruptIfRunning为true,则会立即中断执行任务的线程并返回true,若mayInterruptIfRunning为false,则会返回true且不会中断任务执行线程。
  • isCanceled()判断任务是否被取消,如果任务在结束(正常执行结束或者执行异常结束)前被取消则返回true,否则返回false。
  • isDone()判断任务是否已经完成,如果完成则返回true,否则返回false。
    • 需要注意的是:任务执行过程中发生异常、任务被取消也属于任务已完成,也会返回true
  • get()获取任务执行结果,如果任务还没完成则会阻塞等待直到任务执行完成
    • 如果任务被取消则会抛出CancellationException异常,如果任务执行过程发生异常则会抛出ExecutionException异常,如果阻塞等待过程中被中断则会抛出InterruptedException异常。
  • get(long timeout,Timeunit unit)带超时时间的get()版本,如果阻塞等待过程中超时则会抛出TimeoutException异常
3、核心属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//内部持有的callable任务,运行完毕后置空
private Callable<V> callable;

//从get()中返回的结果或抛出的异常
private Object outcome; // non-volatile, protected by state reads/writes

//运行callable的线程
private volatile Thread runner;

//使用Treiber栈保存等待线程
private volatile WaitNode waiters;

//任务状态
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;

其中需要注意的是**state是volatile类型的**,也就是说只要有任何一个线程修改了这个变量,那么其他所有的线程都会知道最新的值。

7种状态具体表示:

  • NEW:表示是个新的任务或者还没被执行完的任务。这是初始状态
  • COMPLETING任务已经执行完成或者执行任务的时候发生异常,但是任务执行结果或者异常原因还没有保存到outcome字段(outcome字段用来保存任务执行结果,如果发生异常,则用来保存异常原因)的时候,状态会从NEW变更到COMPLETING。但是这个状态会时间会比较短,属于中间状态。
  • NORMAL任务已经执行完成并且任务执行结果已经保存到outcome字段,状态会从COMPLETING转换到NORMAL。这是一个最终态。
  • EXCEPTIONAL任务执行发生异常并且异常原因已经保存到outcome字段中后,状态会从COMPLETING转换到EXCEPTIONAL。这是一个最终态。
  • CANCELLED任务还没开始执行或者已经开始执行但是还没有执行完成的时候,用户调用了cancel(false)方法取消任务且不中断任务执行线程,这个时候状态会从NEW转化为CANCELLED状态。这是一个最终态。
  • INTERRUPTING任务还没开始执行或者已经执行但是还没有执行完成的时候,用户调用了cancel(true)方法取消任务并且要中断任务执行线程但是还没有中断任务执行线程之前,状态会从NEW转化为INTERRUPTING。这是一个中间状态。
  • INTERRUPTED:**调用interrupt()中断任务执行线程之后状态会从INTERRUPTING转换到INTERRUPTED。这是一个最终态。 **
    • 有一点需要注意的是,所有值大于COMPLETING的状态都表示任务已经执行完成(任务正常执行完成,任务执行异常或者任务被取消)。

各个状态之间的可能转换关系如下图所示:

img

4、构造函数
  • FutureTask(Callable callable)

    • public FutureTask(Callable<V> callable) {
          if (callable == null)
              throw new NullPointerException();
          this.callable = callable;
          this.state = NEW;       // ensure visibility of callable
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10

      - 这个构造函数会把传入的Callable变量保存在this.callable字段中,该字段定义为`private Callable<V> callable`;**用来保存底层的调用,在被执行完成以后会指向null,接着会初始化state字段为NEW**。

      - FutureTask(Runnable runnable, V result)

      - ```java
      public FutureTask(Runnable runnable, V result) {
      this.callable = Executors.callable(runnable, result);
      this.state = NEW; // ensure visibility of callable
      }
    • 这个构造函数会把传入的Runnable封装成一个Callable对象保存在callable字段中,同时如果任务执行成功的话就会返回传入的result。这种情况下如果不需要返回值的话可以传入一个null。

    • 顺带看下Executors.callable()这个方法,这个方法的功能是把Runnable转换成Callable,代码如下:

      • public static <T> Callable<T> callable(Runnable task, T result) {
            if (task == null)
               throw new NullPointerException();
            return new RunnableAdapter<T>(task, result);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16

        - 可以看到这里采用的是**适配器模式**,调用`RunnableAdapter<T>(task, result)`方法来适配,实现如下:

        - ```java
        static final class RunnableAdapter<T> implements Callable<T> {
        final Runnable task;
        final T result;
        RunnableAdapter(Runnable task, T result) {
        this.task = task;
        this.result = result;
        }
        public T call() {
        task.run();
        return result;
        }
        }
    • 这个适配器很简单,就是简单的实现了Callable接口,在call()实现中调用Runnable.run()方法,然后把传入的result作为任务的结果返回。

在new了一个FutureTask对象之后,接下来就是在另一个线程中执行这个Task,无论是通过直接new一个Thread还是通过线程池,执行的都是run()方法,接下来就看看run()方法的实现。

5、核心方法——run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void run() {
//新建任务,CAS替换runner为当前线程
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);//设置执行结果
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);//处理中断逻辑
}
}

说明:

  • 运行任务,如果任务状态为NEW状态,则利用CAS修改为当前线程。执行完毕调用set(result)方法设置执行结果。set(result)源码如下:(使用的也是CAS修改state状态为COMPLETING)

    • protected void set(V v) {
          if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
              outcome = v;
              UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
              finishCompletion();//执行完毕,唤醒等待线程
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28

      - 首先利用cas修改state状态为COMPLETING,设置返回结果,然后使用 lazySet(UNSAFE.putOrderedInt)的方式设置state状态为NORMAL。结果设置完毕后,调用finishCompletion()方法唤醒等待线程,源码如下:

      - ```java
      private void finishCompletion() {
      // assert state > COMPLETING;
      for (WaitNode q; (q = waiters) != null;) {
      if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {//移除等待线程
      for (;;) {//自旋遍历等待线程
      Thread t = q.thread;
      if (t != null) {
      q.thread = null;
      LockSupport.unpark(t);//唤醒等待线程
      }
      WaitNode next = q.next;
      if (next == null)
      break;
      q.next = null; // unlink to help gc
      q = next;
      }
      break;
      }
      }
      //任务完成后调用函数,自定义扩展
      done();

      callable = null; // to reduce footprint
      }
  • 回到run方法,如果在 run 期间被中断,此时需要调用handlePossibleCancellationInterrupt方法来处理中断逻辑,确保任何中断(例如cancel(true))只停留在当前run或runAndReset的任务中,源码如下:

    • private void handlePossibleCancellationInterrupt(int s) {
          //在中断者中断线程之前可能会延迟,所以我们只需要让出CPU时间片自旋等待
          if (s == INTERRUPTING)
              while (state == INTERRUPTING)
                  Thread.yield(); // wait out pending interrupt
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      ##### 6、核心方法——get()

      ```java
      //获取执行结果
      public V get() throws InterruptedException, ExecutionException {
      int s = state;
      if (s <= COMPLETING)
      s = awaitDone(false, 0L);
      return report(s);
      }

说明:FutureTask 通过get()方法获取任务执行结果。如果任务处于未完成的状态(state <= COMPLETING),就调用awaitDone方法(后面单独讲解)等待任务完成。任务完成后,通过report方法获取执行结果或抛出执行期间的异常。

report源码如下:

1
2
3
4
5
6
7
8
9
//返回执行结果或抛出异常
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
7、核心方法——awaitDone(boolean timed, long nanos)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private int awaitDone(boolean timed, long nanos)
throws InterruptedException {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
WaitNode q = null;
boolean queued = false;
for (;;) {//自旋
if (Thread.interrupted()) {//获取并清除中断状态
removeWaiter(q);//移除等待WaitNode
throw new InterruptedException();
}

int s = state;
if (s > COMPLETING) {
if (q != null)
q.thread = null;//置空等待节点的线程
return s;
}
else if (s == COMPLETING) // cannot time out yet
Thread.yield();
else if (q == null)
q = new WaitNode();
else if (!queued)
//CAS修改waiter
queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
q.next = waiters, q);
else if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
removeWaiter(q);//超时,移除等待节点
return state;
}
LockSupport.parkNanos(this, nanos);//阻塞当前线程
}
else
LockSupport.park(this);//阻塞当前线程
}
}

说明:awaitDone用于等待任务完成,或任务因为中断或超时而终止。返回任务的完成状态。函数执行逻辑如下:

如果线程被中断,首先清除中断状态,调用removeWaiter移除等待节点,然后抛出InterruptedException。

removeWaiter源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void removeWaiter(WaitNode node) {
if (node != null) {
node.thread = null;//首先置空线程
retry:
for (;;) { // restart on removeWaiter race
//依次遍历查找
for (WaitNode pred = null, q = waiters, s; q != null; q = s) {
s = q.next;
if (q.thread != null)
pred = q;
else if (pred != null) {
pred.next = s;
if (pred.thread == null) // check for race
continue retry;
}
else if (!UNSAFE.compareAndSwapObject(this, waitersOffset,q, s)) //cas替换
continue retry;
}
break;
}
}
}
  • 如果当前状态为结束状态(state>COMPLETING),则根据需要置空等待节点的线程,并返回 Future 状态;
  • 如果当前状态为正在完成(COMPLETING),说明此时 Future 还不能做出超时动作,为任务让出CPU执行时间片;
  • 如果state为NEW,先新建一个WaitNode,然后CAS修改当前waiters;
  • 如果等待超时,则调用removeWaiter移除等待节点,返回任务状态;如果设置了超时时间但是尚未超时,则park阻塞当前线程;
  • 其他情况直接阻塞当前线程。
8、核心方法——cancel(boolean mayInterruptIfRunning)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean cancel(boolean mayInterruptIfRunning) {
//如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED
if (!(state == NEW &&
UNSAFE.compareAndSwapInt(this, stateOffset, NEW,
mayInterruptIfRunning ? INTERRUPTING : CANCELLED)))
return false;
try { // in case call to interrupt throws exception
if (mayInterruptIfRunning) {//可以在运行时中断
try {
Thread t = runner;
if (t != null)
t.interrupt();
} finally { // final state
UNSAFE.putOrderedInt(this, stateOffset, INTERRUPTED);
}
}
} finally {
finishCompletion();//移除并唤醒所有等待线程
}
return true;
}

说明:尝试取消任务。如果任务已经完成或已经被取消,此操作会失败。

  • 如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED。
  • 如果当前状态不为NEW,则根据参数mayInterruptIfRunning决定是否在任务运行中也可以中断。中断操作完成后,调用finishCompletion移除并唤醒所有等待线程。

5、FutureTask示例

常用使用方式:

  • 第一种方式:Future + ExecutorService
  • 第二种方式:FutureTask + ExecutorService
  • 第三种方式:FutureTask + Thread
1、Future使用示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FutureDemo {
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
Future future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
Long start = System.currentTimeMillis();
while (true) {
Long current = System.currentTimeMillis();
if ((current - start) > 1000) {
return 1;
}
}
}
});

try {
Integer result = (Integer)future.get();
System.out.println(result);
}catch (Exception e){
e.printStackTrace();
}
}
}
2、FutureTask + Thread例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import java.util.concurrent.*;

public class CallDemo {

public static void main(String[] args) throws ExecutionException, InterruptedException {

/**
* 第一种方式:Future + ExecutorService
* Task task = new Task();
* ExecutorService service = Executors.newCachedThreadPool();
* Future<Integer> future = service.submit(task);
* service.shutdown();
*/


/**
* 第二种方式: FutureTask + ExecutorService
* ExecutorService executor = Executors.newCachedThreadPool();
* Task task = new Task();
* FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
* executor.submit(futureTask);
* executor.shutdown();
*/

/**
* 第三种方式:FutureTask + Thread
*/

// 2. 新建FutureTask,需要一个实现了Callable接口的类的实例作为构造函数参数
FutureTask<Integer> futureTask = new FutureTask<Integer>(new Task());
// 3. 新建Thread对象并启动
Thread thread = new Thread(futureTask);
thread.setName("Task thread");
thread.start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");

// 4. 调用isDone()判断任务是否结束
if(!futureTask.isDone()) {
System.out.println("Task is not done");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int result = 0;
try {
// 5. 调用get()方法获取任务结果,如果任务没有执行完成则阻塞等待
result = futureTask.get();
} catch (Exception e) {
e.printStackTrace();
}

System.out.println("result is " + result);

}

// 1. 继承Callable接口,实现call()方法,泛型参数为要返回的类型
static class Task implements Callable<Integer> {

@Override
public Integer call() throws Exception {
System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");
int result = 0;
for(int i = 0; i < 100;++i) {
result += i;
}

Thread.sleep(3000);
return result;
}
}
}

15、JUC强大的辅助类

1、CountDownLatch(减少计数)

CountDownLatch底层也是由AQS,用来同步一个或多个任务的常用并发工具类,强制它们等待由其他任务执行的一组操作完成。

1、BAT大厂的面试问题
  • 什么是CountDownLatch?
  • CountDownLatch底层实现原理?
  • CountDownLatch一次可以唤醒几个任务?
    • 多个
  • CountDownLatch有哪些主要方法?
    • await()、countDown()
  • CountDownLatch适用于什么场景?
  • 写道题:实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束?
    • 使用CountDownLatch 代替wait notify 好处。
2、CountDownLatch介绍

从源码可知,其底层是由AQS提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列:同步队列sync queue条件队列condition queue,不同的条件会有不同的条件队列。

CountDownLatch主要用来进行线程同步协作,等待所有线程完成倒计时。其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一

CountDownLatch典型的用法是:将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。

3、CountDownLatch源码分析
1、类的继承关系

CountDownLatch没有显示继承哪个父类或者实现哪个父接口,它底层是AQS是通过内部类Sync来实现的

1
public class CountDownLatch {}
2、类的内部类

CountDownLatch类存在一个内部类Sync,继承自AbstractQueuedSynchronizer,(AQS)其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static final class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 4982264981922014374L;

// 构造器
Sync(int count) {
setState(count);
}

// 返回当前计数
int getCount() {
return getState();
}

// 试图在共享模式下获取对象状态
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}

// 试图设置状态来反映共享模式下的一个释放
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 无限循环
for (;;) {
// 获取状态
int c = getState();
if (c == 0) // 没有被线程占有
return false;
// 下一个状态
int nextc = c-1;
if (compareAndSetState(c, nextc)) // 比较并且设置成功
return nextc == 0;
}
}
}

说明:对CountDownLatch方法的调用会转发到对Sync或AQS的方法的调用,所以,AQS对CountDownLatch提供支持。

3、类的属性

CountDownLatch类的内部只有一个Sync类型的属性:

1
2
3
4
public class CountDownLatch {
// 同步队列
private final Sync sync;
}
4、类的构造函数
1
2
3
4
5
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
// 初始化状态数
this.sync = new Sync(count);
}

说明:该构造函数可以构造一个用给定计数初始化的CountDownLatch,并且构造函数内完成了sync的初始化,并设置了状态数

5、核心函数——await函数

此函数将会使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。其源码如下:

1
2
3
4
public void await() throws InterruptedException {
// 转发到sync对象上
sync.acquireSharedInterruptibly(1);
}

说明:由源码可知,对CountDownLatch对象的await的调用会转发为对Sync的acquireSharedInterruptibly(从AQS继承的方法)方法的调用

  • acquireSharedInterruptibly源码如下:

    • public final void acquireSharedInterruptibly(int arg)
              throws InterruptedException {
          if (Thread.interrupted())
              throw new InterruptedException();
          if (tryAcquireShared(arg) < 0)
              doAcquireSharedInterruptibly(arg);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 说明:从源码中可知,acquireSharedInterruptibly又调用了CountDownLatch的内部类Sync的tryAcquireShared和AQS的doAcquireSharedInterruptibly函数。

      - tryAcquireShared函数的源码如下:

      - ```java
      protected int tryAcquireShared(int acquires) {
      return (getState() == 0) ? 1 : -1;
      }
    • 说明:该函数只是简单的判断AQS的state是否为0,为0则返回1,不为0则返回-1。

  • doAcquireSharedInterruptibly函数的源码如下:

    • private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
          // 添加节点至等待队列
          final Node node = addWaiter(Node.SHARED);
          boolean failed = true;
          try {
              for (;;) { // 无限循环
                  // 获取node的前驱节点
                  final Node p = node.predecessor();
                  if (p == head) { // 前驱节点为头结点
                      // 试图在共享模式下获取对象状态
                      int r = tryAcquireShared(arg);
                      if (r >= 0) { // 获取成功
                          // 设置头结点并进行繁殖
                          setHeadAndPropagate(node, r);
                          // 设置节点next域
                          p.next = null; // help GC
                          failed = false;
                          return;
                      }
                  }
                  if (shouldParkAfterFailedAcquire(p, node) &&
                      parkAndCheckInterrupt()) // 在获取失败后是否需要禁止线程并且进行中断检查
                      // 抛出异常
                      throw new InterruptedException();
              }
          } finally {
              if (failed)
                  cancelAcquire(node);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41

      - 说明:在AQS的doAcquireSharedInterruptibly中可能会再次调用CountDownLatch的内部类Sync的tryAcquireShared方法和AQS的setHeadAndPropagate方法。

      - setHeadAndPropagate方法源码如下:

      - ```java
      private void setHeadAndPropagate(Node node, int propagate) {
      // 获取头结点
      Node h = head; // Record old head for check below
      // 设置头结点
      // 设置自己为 head
      setHead(node);
      /*
      * Try to signal next queued node if:
      * Propagation was indicated by caller,
      * or was recorded (as h.waitStatus either before
      * or after setHead) by a previous operation
      * (note: this uses sign-check of waitStatus because
      * PROPAGATE status may transition to SIGNAL.)
      * and
      * The next node is waiting in shared mode,
      * or we don't know, because it appears null
      *
      * The conservatism in both of these checks may cause
      * unnecessary wake-ups, but only when there are multiple
      * racing acquires/releases, so most need signals now or soon
      * anyway.
      */
      // propagate 表示有共享资源(例如共享读锁或信号量)
      // 原 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
      // 现在 head waitStatus == Node.SIGNAL 或 Node.PROPAGATE
      if (propagate > 0 || h == null || h.waitStatus < 0 ||
      (h = head) == null || h.waitStatus < 0) {
      // 获取节点的后继
      Node s = node.next;
      // 如果是最后一个节点或者是等待共享读锁的节点
      if (s == null || s.isShared()) // 后继为空或者为共享模式
      // 以共享模式进行释放
      doReleaseShared();
      }
      }
    • 说明:该方法设置头结点并且释放头结点后面的满足条件的结点,该方法中可能会调用到AQS的doReleaseShared方法。

  • AQS的doReleaseShared方法其源码如下:

    • private void doReleaseShared() {
          /*
              * Ensure that a release propagates, even if there are other
              * in-progress acquires/releases.  This proceeds in the usual
              * way of trying to unparkSuccessor of head if it needs
              * signal. But if it does not, status is set to PROPAGATE to
              * ensure that upon release, propagation continues.
              * Additionally, we must loop in case a new node is added
              * while we are doing this. Also, unlike other uses of
              * unparkSuccessor, we need to know if CAS to reset status
              * fails, if so rechecking.
              */
          // 无限循环
          // 如果 head.waitStatus == Node.SIGNAL ==> 0 成功, 下一个节点 unpark
          // 如果 head.waitStatus == 0 ==> Node.PROPAGATE, 为了解决 bug, 见后面分析
          for (;;) {
              // 保存头结点
              Node h = head;
              // 队列还有节点
              if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
                  // 获取头结点的等待状态
                  int ws = h.waitStatus; 
                  if (ws == Node.SIGNAL) { // 状态为SIGNAL
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
                          continue;            // loop to recheck cases
                      // 下一个节点 unpark 如果成功获取读锁
                      // 并且下下个节点还是 shared, 继续 doReleaseShared
                      // 释放后继结点
                      unparkSuccessor(h);
                  }
                  else if (
                      // 如果已经是 0 了,改为 -3,用来解决传播性,见后文信号量 bug 分析
                      ws == 0 &&
                      !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
                      continue;                // loop on failed CAS
              }
              if (h == head) // 若头结点改变,继续循环  
                  break;
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
          
      - 说明:**该方法在共享模式下释放**,具体的流程再之后会通过一个示例给出。

      所以,对CountDownLatch的await调用大致会有如下的调用链:

      ![img](JUC/java-thread-x-countdownlatch-1.png)

      说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。

      ###### 6、核心函数——countDown函数

      此函数将递减锁存器的计数,如果计数到达零,则释放所有等待的线程。

      ```java
      public void countDown() {
      sync.releaseShared(1);
      }

说明:对countDown的调用转换为对Sync对象的releaseShared(从AQS继承而来)方法的调用

  • releaseShared源码如下:

    • public final boolean releaseShared(int arg) {
          if (tryReleaseShared(arg)) {
              doReleaseShared();
              return true;
          }
          return false;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20

      - 说明:**此函数会以共享模式释放对象,并且在函数中会调用到CountDownLatch的tryReleaseShared函数,并且可能会调用AQS的doReleaseShared函数**。

      - tryReleaseShared源码如下:

      - ```java
      protected boolean tryReleaseShared(int releases) {
      // Decrement count; signal when transition to zero
      // 无限循环
      for (;;) {
      // 获取状态
      int c = getState();
      if (c == 0) // 没有被线程占有
      return false;
      // 下一个状态
      int nextc = c-1;
      if (compareAndSetState(c, nextc)) // 比较并且设置成功
      return nextc == 0;
      }
      }
    • 说明:此函数会试图设置状态来反映共享模式下的一个释放。具体的流程在下面的示例中会进行分析。

  • AQS的doReleaseShared的源码如下:

    • private void doReleaseShared() {
          /*
              * Ensure that a release propagates, even if there are other
              * in-progress acquires/releases.  This proceeds in the usual
              * way of trying to unparkSuccessor of head if it needs
              * signal. But if it does not, status is set to PROPAGATE to
              * ensure that upon release, propagation continues.
              * Additionally, we must loop in case a new node is added
              * while we are doing this. Also, unlike other uses of
              * unparkSuccessor, we need to know if CAS to reset status
              * fails, if so rechecking.
              */
          // 无限循环
          for (;;) {
              // 保存头结点
              Node h = head;
              if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
                  // 获取头结点的等待状态
                  int ws = h.waitStatus; 
                  if (ws == Node.SIGNAL) { // 状态为SIGNAL
                      if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
                          continue;            // loop to recheck cases
                      // 释放后继结点
                      unparkSuccessor(h);
                  }
                  else if (ws == 0 &&
                              !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
                      continue;                // loop on failed CAS
              }
              if (h == head) // 若头结点改变,继续循环  
                  break;
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52

      - 说明:此函数在共享模式下释放资源。

      所以,对CountDownLatch的countDown调用大致会有如下的调用链:

      ![img](JUC/java-thread-x-countdownlatch-2.png)

      说明:上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。

      ##### 4、CountDownLatch示例

      下面给出了一个使用CountDownLatch的示例:

      ```java
      import java.util.concurrent.CountDownLatch;

      class MyThread extends Thread {
      private CountDownLatch countDownLatch;

      public MyThread(String name, CountDownLatch countDownLatch) {
      super(name);
      this.countDownLatch = countDownLatch;
      }

      public void run() {
      System.out.println(Thread.currentThread().getName() + " doing something");
      try {
      Thread.sleep(1000);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + " finish");
      countDownLatch.countDown();
      }
      }

      public class CountDownLatchDemo {
      public static void main(String[] args) {
      CountDownLatch countDownLatch = new CountDownLatch(2);
      MyThread t1 = new MyThread("t1", countDownLatch);
      MyThread t2 = new MyThread("t2", countDownLatch);
      t1.start();
      t2.start();
      System.out.println("Waiting for t1 thread and t2 thread to finish");
      try {
      countDownLatch.await();
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println(Thread.currentThread().getName() + " continue");
      }
      }

运行结果(某一次):

1
2
3
4
5
6
Waiting for t1 thread and t2 thread to finish
t1 doing something
t2 doing something
t1 finish
t2 finish
main continue

说明:本程序首先计数器初始化为2。根据结果,可能会存在如下的一种时序图:

img

说明:首先main线程会调用await操作,此时main线程会被阻塞,等待被唤醒,之后t1线程执行了countDown操作,最后,t2线程执行了countDown操作,此时main线程就被唤醒了,可以继续运行。下面,进行详细分析:

  • main线程执行countDownLatch.await操作,主要调用的函数如下:
    • img
    • 说明:在最后,main线程就被park了,即禁止运行了。此时Sync queue(同步队列)中有两个节点,AQS的state为2,包含main线程的结点的nextWaiter指向SHARED结点。
  • t1线程执行countDownLatch.countDown操作,主要调用的函数如下:
    • img
    • 说明:此时,Sync queue队列里的结点个数未发生变化,但是此时,AQS的state已经变为1了。
  • t2线程执行countDownLatch.countDown操作,主要调用的函数如下:
    • img
    • 说明:经过调用后,AQS的state为0,并且此时,main线程会被unpark,可以继续运行。当main线程获取cpu资源后,继续运行。
  • main线程获取cpu资源,继续运行,由于main线程是在parkAndCheckInterrupt函数中被禁止的,所以此时,继续在parkAndCheckInterrupt函数运行。
    • img
    • 说明:main线程恢复,继续在parkAndCheckInterrupt函数中运行,之后又会回到最终达到的状态为:AQS的state为0,并且head与tail指向同一个结点,该节点的nextWaiter域还是指向SHARED结点。
5、更深入理解
1、面试题

实现一个容器,提供两个方法,add,size 写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。

2、使用wait和notify实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.ArrayList;
import java.util.List;

/**
* 必须先让t2先进行启动 使用wait 和 notify 进行相互通讯,wait会释放锁,notify不会释放锁
*/
public class T2 {

volatile List list = new ArrayList();

public void add (int i){
list.add(i);
}

public int getSize(){
return list.size();
}

public static void main(String[] args) {

T2 t2 = new T2();

Object lock = new Object();

new Thread(() -> {
synchronized(lock){
System.out.println("t2 启动");
if(t2.getSize() != 5){
try {
/**会释放锁*/
lock.wait();
System.out.println("t2 结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**唤醒T1进程*/
lock.notify();
}
},"t2").start();

new Thread(() -> {
synchronized (lock){
System.out.println("t1 启动");
for (int i=0;i<9;i++){
t2.add(i);
System.out.println("add"+i);
if(t2.getSize() == 5){
/**不会释放锁*/
lock.notify();
try {
/**进程挂起,释放锁等待唤醒*/
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
},"t1").start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
t2 启动
t1 启动
add0
add1
add2
add3
add4
t2 结束
add5
add6
add7
add8
3、CountDownLatch实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;

/**
* 使用CountDownLatch 代替wait notify 好处是通讯方式简单,不涉及锁定 Count 值为0时当前线程继续执行,
*/
public class T3 {

volatile List list = new ArrayList();

public void add(int i){
list.add(i);
}

public int getSize(){
return list.size();
}


public static void main(String[] args) {
T3 t = new T3();
CountDownLatch countDownLatch = new CountDownLatch(1);

new Thread(() -> {
System.out.println("t2 start");
if(t.getSize() != 5){
try {
countDownLatch.await();
System.out.println("t2 end");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t2").start();

new Thread(()->{
System.out.println("t1 start");
for (int i = 0;i<9;i++){
t.add(i);
System.out.println("add"+ i);
if(t.getSize() == 5){
System.out.println("countdown is open");
countDownLatch.countDown();
}
}
System.out.println("t1 end");
},"t1").start();
}

}

2、CyclicBarrier(循环栅栏)

CyclicBarrier底层是基于ReentrantLockAbstractQueuedSynchronizer来实现的,在理解的时候最好和CountDownLatch放在一起理解。

1、BAT大厂的面试问题
  • 什么是CyclicBarrier?
  • CyclicBarrier底层实现原理?
  • CountDownLatch和CyclicBarrier对比?
  • CyclicBarrier的核心函数有哪些?
  • CyclicBarrier适用于什么场景?
2、CyclicBarrier简介
  • 对于CountDownLatch,其他线程为游戏玩家,比如英雄联盟,主线程为控制游戏开始的线程。在所有的玩家都准备好之前,主线程是处于等待状态的,也就是游戏不能开始。当所有的玩家准备好之后,下一步的动作实施者为主线程,即开始游戏。
  • 对于CyclicBarrier,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。
  • 注意:CyclicBarrier的计数与线程数最好是一一对应才能达到我们的要求
    • 例子:一开始两个任务task1(执行1s)与task2(执行2s),需要循环执行3次,即两对三次总共六次任务,每一对任务执行完毕会执行CyclicBarrier当中的任务task3,所以设置CyclicBarrier的计数为2,对应每一组两个任务
    • 如果我们设置线程池的线程个数为2,那么会如我们所想执行——task1 task2 task3 task1 task2 task3 task1 task2 task3
    • 如果我们设置线程池的线程个数为3,那么就不会如我们所想执行了,因为一开始会有三个线程线执行任务:task1 task2 task1,而CyclicBarrier的task3会被两个task1执行(因为1 + 1 = 2)结束后执行,执行流程就变成——task1 task1 task3 task2 task1 task3 task2 task2 task3
3、CyclicBarrier源码分析
1、类的继承关系

CyclicBarrier没有显示继承哪个父类或者实现哪个父接口,所有AQS和重入锁不是通过继承实现的,而是通过组合实现的。

1
public class CyclicBarrier {}
2、类的内部类

CyclicBarrier类存在一个内部类Generation,每一次使用的CycBarrier可以当成Generation的实例,其源代码如下:

1
2
3
private static class Generation {
boolean broken = false;
}

说明:Generation类有一个属性broken,用来表示当前屏障是否被损坏

3、类的属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CyclicBarrier {
/** The lock for guarding barrier entry */
// 可重入锁
private final ReentrantLock lock = new ReentrantLock();
/** Condition to wait on until tripped */
// 条件队列
private final Condition trip = lock.newCondition();
/** The number of parties */
// 参与的线程数量
private final int parties;
/* The command to run when tripped */
// 由最后一个进入 barrier 的线程执行的操作
private final Runnable barrierCommand;
/** The current generation */
// 当前代
private Generation generation = new Generation();
// 正在等待进入屏障的线程数量
private int count;
}

说明:该属性有一个为ReentrantLock对象,有一个为Condition对象,而Condition对象又是基于AQS的,所以,归根到底,底层还是由AQS提供支持

4、类的构造函数
  • CyclicBarrier(int, Runnable)型构造函数:

    • public CyclicBarrier(int parties, Runnable barrierAction) {
          // 参与的线程数量小于等于0,抛出异常
          if (parties <= 0) throw new IllegalArgumentException();
          // 设置parties
          this.parties = parties;
          // 设置count
          this.count = parties;
          // 设置barrierCommand
          this.barrierCommand = barrierAction;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 说明:该构造函数可以指定关联该CyclicBarrier的线程数量,并且可以指定在所有线程都进入屏障后的执行动作,该执行动作由最后一个进行屏障的线程执行。

      - CyclicBarrier(int)型构造函数:

      - ```java
      public CyclicBarrier(int parties) {
      // 调用含有两个参数的构造函数
      this(parties, null);
      }

    • 说明:该构造函数仅仅执行了关联该CyclicBarrier的线程数量,没有设置执行动作。

5、核心函数——dowait函数

此函数为CyclicBarrier类的核心函数,CyclicBarrier类对外提供的await函数在底层都是调用该类的doawait函数

await函数源代码如下:

1
2
3
4
5
6
7
public int await() throws InterruptedException, BrokenBarrierException {
try {
return dowait(false, 0L);
} catch (TimeoutException toe) {
throw new Error(toe); // cannot happen
}
}

doawait函数源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
// 保存当前锁
final ReentrantLock lock = this.lock;
// 锁定
lock.lock();
try {
// 保存当前代
final Generation g = generation;

if (g.broken) // 屏障被破坏,抛出异常
throw new BrokenBarrierException();

if (Thread.interrupted()) { // 线程被中断
// 损坏当前屏障,并且唤醒所有的线程,只有拥有锁的时候才会调用
breakBarrier();
// 抛出异常
throw new InterruptedException();
}

// 减少正在等待进入屏障的线程数量
int index = --count;
if (index == 0) { // 正在等待进入屏障的线程数量为0,所有线程都已经进入
// 运行的动作标识
boolean ranAction = false;
try {
// 保存运行动作
final Runnable command = barrierCommand;
if (command != null) // 动作不为空
// 运行
command.run();
// 设置ranAction状态
ranAction = true;
// 进入下一代
nextGeneration();
return 0;
} finally {
if (!ranAction) // 没有运行的动作
// 损坏当前屏障
breakBarrier();
}
}

// loop until tripped, broken, interrupted, or timed out
// 无限循环
for (;;) {
try {
if (!timed) // 没有设置等待时间
// 等待
trip.await();
else if (nanos > 0L) // 设置了等待时间,并且等待时间大于0
// 等待指定时长
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) { // 等于当前代并且屏障没有被损坏
// 损坏当前屏障
breakBarrier();
// 抛出异常
throw ie;
} else { // 不等于当前带后者是屏障被损坏
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
// 中断当前线程
Thread.currentThread().interrupt();
}
}

if (g.broken) // 屏障被损坏,抛出异常
throw new BrokenBarrierException();

if (g != generation) // 不等于当前代
// 返回索引
return index;

if (timed && nanos <= 0L) { // 设置了等待时间,并且等待时间小于0
// 损坏屏障
breakBarrier();
// 抛出异常
throw new TimeoutException();
}
}
} finally {
// 释放锁
lock.unlock();
}
}

说明:dowait方法的逻辑会进行一系列的判断,大致流程如下:

img

6、核心函数——nextGenneration函数

此函数在所有线程进入屏障后会被调用,即生成下一个版本,所有线程又可以重新进入到屏障中,其源代码如下:

1
2
3
4
5
6
7
8
9
10
private void nextGeneration() {
// signal completion of last generation
// 唤醒所有线程
trip.signalAll();
// set up next generation
// 恢复正在等待进入屏障的线程数量
count = parties;
// 新生一代
generation = new Generation();
}

在此函数中会调用AQS的signalAll方法,即唤醒所有等待线程

如果所有的线程都在等待此条件,则唤醒所有线程。其源代码如下:

1
2
3
4
5
6
7
8
9
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}

说明:此函数判断头结点是否为空,即条件队列是否为空,然后会调用doSignalAll函数,doSignalAll函数源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 全部唤醒 - 等待队列的所有节点转移至 AQS 队列
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}

说明:此函数会依次将条件队列中的节点转移到同步队列中,会调用到transferForSignal函数,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 如果节点状态是取消, 返回 false 表示转移失败, 否则转移成功
final boolean transferForSignal(Node node) {
/*
* If cannot change waitStatus, the node has been cancelled.
*/
// 如果状态已经不是 Node.CONDITION, 说明被取消了
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;

/*
* Splice onto queue and try to set waitStatus of predecessor to
* indicate that thread is (probably) waiting. If cancelled or
* attempt to set waitStatus fails, wake up to resync (in which
* case the waitStatus can be transiently and harmlessly wrong).
*/
// 加入 AQS 队列尾部
Node p = enq(node);
int ws = p.waitStatus;
if (
// 上一个节点被取消
ws > 0 ||
// 上一个节点不能设置状态为 Node.SIGNAL
!compareAndSetWaitStatus(p, ws, Node.SIGNAL))
// unpark 取消阻塞, 让线程重新同步状态
LockSupport.unpark(node.thread);
return true;
}

说明:此函数的作用就是将处于条件队列中的节点转移到同步队列中,并设置结点的状态信息

其中会调用到enq函数,其源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}

说明:此函数完成了结点插入同步队列的过程,也很好理解。

综合上面的分析可知,newGeneration函数的主要方法的调用如下,之后会通过一个例子详细讲解:

img

7、breakBarrier函数

此函数的作用是损坏当前屏障,会唤醒所有在屏障中的线程。源代码如下:

1
2
3
4
5
6
7
8
private void breakBarrier() {
// 设置状态
generation.broken = true;
// 恢复正在等待进入屏障的线程数量
count = parties;
// 唤醒所有线程
trip.signalAll();
}

说明:可以看到,此函数也调用了AQS的signalAll函数,由signal函数提供支持。

4、CyclicBarrier示例

下面通过一个例子来详解CyclicBarrier的使用和内部工作机制,源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

class MyThread extends Thread {
private CyclicBarrier cb;
public MyThread(String name, CyclicBarrier cb) {
super(name);
this.cb = cb;
}

public void run() {
System.out.println(Thread.currentThread().getName() + " going to await");
try {
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class CyclicBarrierDemo {
public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
CyclicBarrier cb = new CyclicBarrier(3, new Thread("barrierAction") {
public void run() {
System.out.println(Thread.currentThread().getName() + " barrier action");
}
});
MyThread t1 = new MyThread("t1", cb);
MyThread t2 = new MyThread("t2", cb);
t1.start();
t2.start();
System.out.println(Thread.currentThread().getName() + " going to await");
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");

}
}

运行结果(某一次):

1
2
3
4
5
6
7
t1 going to await
main going to await
t2 going to await
t2 barrier action
t2 continue
t1 continue
main continue

说明:根据结果可知,可能会存在如下的调用时序:

java-thread-x-cyclicbarrier-3

说明:由上图可知,假设t1线程的cb.await是在main线程的cb.await之前,cb.barrierAction动作是由最后一个进入屏障的线程t2执行的。根据时序图,进一步分析出其内部工作流程。

  • main(主)线程执行cb.await操作,主要调用的函数如下:
    • img
    • 说明:由于ReentrantLock的默认采用非公平策略,所以在dowait函数中调用的是ReentrantLock.NonfairSync的lock函数,由于此时AQS的状态是0,表示还没有被任何线程占用,故main线程可以占用,之后在dowait中会调用trip.await函数,最终的结果是条件队列中存放了一个包含main线程的结点,并且被禁止运行了,同时,main线程所拥有的资源也被释放了,可以供其他线程获取。
  • t1线程执行cb.await操作,其中假设t1线程的lock.lock操作在main线程释放了资源之后,则其主要调用的函数如下:
    • img
    • 说明:可以看到,之后condition queue(条件队列)里面有两个节点,包含t1线程的结点插入在队列的尾部,并且t1线程也被禁止了,因为执行了park操作,此时两个线程都被禁止了。
  • t2线程执行cb.await操作,其中假设t2线程的lock.lock操作在t1线程释放了资源之后,则其主要调用的函数如下:
    • img
    • 说明:由上图可知,在t2线程执行await操作后,会直接执行command.run方法,不是重新开启一个线程,而是最后进入屏障的线程执行。同时,会将Condition queue中的所有节点都转移到Sync queue中,并且最后main线程会被unpark,可以继续运行。main线程获取cpu资源,继续运行。
  • main线程获取cpu资源,继续运行,下图给出了主要的方法调用:
    • img
    • 说明:其中,由于main线程是在AQS.CO的wait中被park的,所以恢复时,会继续在该方法中运行。运行过后,t1线程被unpark,它获得cpu资源可以继续运行。
  • t1线程获取cpu资源,继续运行,下图给出了主要的方法调用:
    • img
    • 说明:其中,由于t1线程是在AQS.CO的wait方法中被park,所以恢复时,会继续在该方法中运行。运行过后,Sync queue中保持着一个空节点。头结点与尾节点均指向它。

注意:在线程await过程中中断线程会抛出异常,所有进入屏障的线程都将被释放。至于CyclicBarrier的其他用法,读者可以自行查阅API。

5、新增一个容易理解的例子

场景:收集七龙珠召唤神龙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;

//集齐7颗龙珠就可以召唤神龙
public class CyclicBarrierDemo {

//创建固定值
private static final int NUMBER = 7;

public static void main(String[] args) {
//创建CyclicBarrier
CyclicBarrier cyclicBarrier =
new CyclicBarrier(NUMBER,()->{
System.out.println("集齐7颗龙珠就可以召唤神龙");
});

//集齐七颗龙珠过程
for (int i = 1; i <=7; i++) {
new Thread(()->{
try {
System.out.println(Thread.currentThread().getName()+" 星龙珠被收集到了");
//等待
cyclicBarrier.await();
} catch (Exception e) {
e.printStackTrace();
}
},String.valueOf(i)).start();
}
}
}

某一次执行结果:

1
2
3
4
5
6
7
8
2 星龙珠被收集到了
4 星龙珠被收集到了
3 星龙珠被收集到了
1 星龙珠被收集到了
7 星龙珠被收集到了
6 星龙珠被收集到了
5 星龙珠被收集到了
集齐7颗龙珠就可以召唤神龙

若将主线程的循环从7改成6的话,由于只能收集到6颗龙珠,所以不能召唤神龙(其实就是没能达到破坏屏障的条件,所有的线程都在等待)

1
2
3
4
5
6
2 星龙珠被收集到了
5 星龙珠被收集到了
4 星龙珠被收集到了
3 星龙珠被收集到了
1 星龙珠被收集到了
6 星龙珠被收集到了
6、和CountDownLatch再对比
  • CountDownLatch减计数,CyclicBarrier加计数。
  • CountDownLatch是一次性的,CyclicBarrier可以重用。
  • CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。

3、Semaphore(信号量)

Semaphore底层是基于AbstractQueuedSynchronizer(AQS)来实现的。Semaphore称为计数信号量,它允许n个任务同时访问某个资源,可以将信号量看做是在向外分发使用资源的许可证,只有成功获取许可证,才能使用资源,用来限制能同时访问共享资源的线程上限。

1、BAT大厂的面试问题
  • 什么是Semaphore?
  • Semaphore内部原理?
  • Semaphore常用方法有哪些?如何实现线程同步和互斥的?
  • Semaphore适合用在什么场景?
  • 单独使用Semaphore是不会使用到AQS的条件队列?
  • Semaphore中申请令牌(acquire)、释放令牌(release)的实现?
  • Semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
  • Semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
  • Semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
  • Semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
2、Semaphore源码分析
1、类的继承关系
1
public class Semaphore implements java.io.Serializable {}

说明:Semaphore实现了Serializable接口,即可以进行序列化

2、类的内部类

Semaphore总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系:

image

说明:Semaphore与ReentrantLock的内部类的结构相同,类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。

3、类的内部类——Sync类

Sync类的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 内部类,继承自AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 1192457210091910933L;

// 构造函数
Sync(int permits) {
// 设置状态数
// permits 即 state
setState(permits);
}

// 获取许可
final int getPermits() {
return getState();
}

// 共享模式下非公平策略获取
final int nonfairTryAcquireShared(int acquires) {
for (;;) { // 无限循环
// 获取许可数
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (
// 如果许可已经用完, 返回负数, 表示获取失败, 进入 doAcquireSharedInterruptibly
remaining < 0 ||
// 如果 cas 重试成功, 返回正数, 表示获取成功
compareAndSetState(available, remaining)) // 许可小于0或者比较并且设置状态成功
return remaining;
}
}

// 共享模式下进行释放
protected final boolean tryReleaseShared(int releases) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return true;
}
}

// 根据指定的缩减量减小可用许可的数目
final void reducePermits(int reductions) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current - reductions;
if (next > current) // underflow
throw new Error("Permit count underflow");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return;
}
}

// 获取并返回立即可用的所有许可
final int drainPermits() {
for (;;) { // 无限循环
// 获取许可
int current = getState();
if (current == 0 || compareAndSetState(current, 0)) // 许可为0或者比较并设置成功
return current;
}
}
}

说明:Sync类的属性相对简单,只有一个版本号,Sync类存在如下方法和作用如下:

img

4、类的内部类——NonfairSync类

NonfairSync类继承了Sync类,表示采用非公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static final class NonfairSync extends Sync {
// 版本号
private static final long serialVersionUID = -2694183684443567898L;

// 构造函数
NonfairSync(int permits) {
super(permits);
}
// 共享模式下获取
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}

说明:从tryAcquireShared方法的源码可知,其会调用父类Sync的nonfairTryAcquireShared方法,表示按照非公平策略进行资源的获取。

5、类的内部类——FairSync类

FairSync类继承了Sync类,表示采用公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
protected int tryAcquireShared(int acquires) {
for (;;) { // 无限循环
if (hasQueuedPredecessors()) // 同步队列中存在其他节点
return -1;
// 获取许可
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining)) // 剩余的许可小于0或者比较设置成功
return remaining;
}
}

说明:从tryAcquireShared方法的源码可知,它使用公平策略来获取资源,它会判断同步队列中是否存在其他的等待节点

6、类的属性
1
2
3
4
5
6
public class Semaphore implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = -3222578661600680210L;
// 属性
private final Sync sync;
}

说明:Semaphore自身只有两个属性,最重要的是sync属性,基于Semaphore对象的操作绝大多数都转移到了对sync的操作

7、类的构造函数
  • Semaphore(int)型构造函数

    • public Semaphore(int permits) {
          sync = new NonfairSync(permits);
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9

      - 说明:该构造函数会创建具有给定的许可数和**非公平的设置的Semaphore**。

      - Semaphore(int, boolean)型构造函数

      - ```java
      public Semaphore(int permits, boolean fair) {
      sync = fair ? new FairSync(permits) : new NonfairSync(permits);
      }
    • 说明:该构造函数会创建具有给定的许可数和给定的公平设置的Semaphore

8、核心函数——acquire函数

此方法从信号量获取一个(多个)许可,在提供一个许可前一直将线程阻塞,或者线程被中断,其源码如下:

1
2
3
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
1
2
3
4
public void acquire(int permits) throws InterruptedException {
if (permits < 0) throw new IllegalArgumentException();
sync.acquireSharedInterruptibly(permits);
}

说明:该方法中将会调用Sync对象的acquireSharedInterruptibly(从AQS继承而来的方法)方法,而acquireSharedInterruptibly方法在上面CountDownLatch中已经进行了分析,在此不再累赘。

最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:

img

说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。

9、核心函数——release函数

此方法释放一个(多个)许可,将其返回给信号量,源码如下:

1
2
3
public void release() {
sync.releaseShared(1);
}

说明:该方法中将会调用Sync对象的releaseShared(从AQS继承而来的方法)方法,而releaseShared方法在上面CountDownLatch中已经进行了分析,在此不再累赘。

最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:

img

说明:上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。

10、图解Semaphore的执行流程

加锁解锁流程

  1. Semaphore 有点像一个停车场,permits 就好像停车位数量,当线程获得了 permits 就像是获得了停车位,然后停车场显示空余车位减一

  2. 刚开始,permits(state)为 3,这时 5 个线程来获取资源

    image-20210813191406684

  3. 假设其中 Thread-1,Thread-2,Thread-4 cas 竞争成功,而 Thread-0 和 Thread-3 竞争失败,进入 AQS 队列park 阻塞

    image-20210813192404706

  4. 这时 Thread-4 释放了 permits,状态如下

    image-20210813192655068

  5. 接下来 Thread-0 竞争成功,permits 再次设置为 0,设置自己为 head 节点,断开原来的 head 节点,unpark 接下来的 Thread-3 节点,但由于 permits 是 0,因此 Thread-3 在尝试不成功后再次进入 park 状态

    image-20210813193133463

3、Semaphore示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.concurrent.Semaphore;

class MyThread extends Thread {
private Semaphore semaphore;

public MyThread(String name, Semaphore semaphore) {
super(name);
this.semaphore = semaphore;
}

public void run() {
int count = 3;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(count);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(count);
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}

public class SemaphoreDemo {
public final static int SEM_SIZE = 10;

public static void main(String[] args) {
Semaphore semaphore = new Semaphore(SEM_SIZE);
MyThread t1 = new MyThread("t1", semaphore);
MyThread t2 = new MyThread("t2", semaphore);
t1.start();
t2.start();
int permits = 5;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(permits);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}

运行结果(某一次):

1
2
3
4
5
6
7
8
9
main trying to acquire
main acquire successfully
t1 trying to acquire
t1 acquire successfully
t2 trying to acquire
t1 release successfully
main release successfully
t2 acquire successfully
t2 release successfully

说明:首先,生成一个信号量,信号量有10个许可,然后,main,t1,t2三个线程获取许可运行,根据结果,可能存在如下的一种时序:

img

说明:如上图所示,首先,main线程执行acquire操作,并且成功获得许可,之后t1线程执行acquire操作,成功获得许可,之后t2执行acquire操作,由于此时许可数量不够,t2线程将会阻塞,直到许可可用。之后t1线程释放许可,main线程释放许可,此时的许可数量可以满足t2线程的要求,所以,此时t2线程会成功获得许可运行,t2运行完成后释放许可。下面进行详细分析:

  • main线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到只是AQS的state变为了5,main线程并没有被阻塞,可以继续运行。
  • t1线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到只是AQS的state变为了2,t1线程并没有被阻塞,可以继续运行。
  • t2线程执行semaphore.acquire操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程获取许可不会成功,之后会导致其被禁止运行,值得注意的是,AQS的state还是为2。
  • t1执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程将会被unpark,并且AQS的state为5,t2获取cpu资源后可以继续运行。
  • main线程执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:此时,t2线程还会被unpark,但是不会产生影响,此时,只要t2线程获得CPU资源就可以运行了。此时,AQS的state为10。
  • t2获取CPU资源,继续运行,此时t2需要恢复现场,回到parkAndCheckInterrupt函数中,也是在should继续运行。主要的函数调用如下图所示:
    • img
    • 说明:此时,可以看到,Sync queue中只有一个结点,头结点与尾节点都指向该结点,在setHeadAndPropagate的函数中会设置头结点并且会unpark队列中的其他结点。
  • t2线程执行semaphore.release操作。主要的函数调用如下图所示:
    • img
    • 说明:t2线程经过release后,此时信号量的许可又变为10个了,此时Sync queue中的结点还是没有变化。
4、新增一个容易理解的例子

场景:6辆汽车,停3个车位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.Random;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

//6辆汽车,停3个车位
public class SemaphoreDemo {
public static void main(String[] args) {
//创建Semaphore,设置许可数量
Semaphore semaphore = new Semaphore(3);

//模拟6辆汽车
for (int i = 1; i <=6; i++) {
new Thread(()->{
try {
//抢占
semaphore.acquire();

System.out.println(Thread.currentThread().getName()+" 抢到了车位");

//设置随机停车时间
TimeUnit.SECONDS.sleep(new Random().nextInt(5));

System.out.println(Thread.currentThread().getName()+" ------离开了车位");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放
semaphore.release();
}
},String.valueOf(i)).start();
}
}
}

某一次执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
1 抢到了车位
2 抢到了车位
3 抢到了车位
2 ------离开了车位
4 抢到了车位
1 ------离开了车位
3 ------离开了车位
5 抢到了车位
6 抢到了车位
4 ------离开了车位
5 ------离开了车位
6 ------离开了车位
5、Semaphore应用
  • 使用Semaphore限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制线程数,而不是限制资源数(例如连接数,请对比Tomcat LimitLatch的实现)
  • Semaphore比较适用于资源数与线程数相等的场景
  • 用 Semaphore 实现简单连接池(一个线程对应一个数据库连接),对比享元模式下的实现(用wait notify),性能和可读性显然更好,注意下面的实现中线程数和数据库连接数是相等的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import lombok.extern.slf4j.Slf4j;

import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicIntegerArray;

public class TestPoolSemaphore {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}

@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;

// 2. 连接对象数组
private Connection[] connections;

// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;

private Semaphore semaphore;

// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
// 让许可数与资源数一致
this.semaphore = new Semaphore(poolSize);
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}

// 5. 借连接
public Connection borrow() {// t1, t2, t3
// 获取许可
try {
semaphore.acquire(); // 没有许可的线程,在此等待
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 不会执行到这里
return null;
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
log.debug("free {}", conn);
semaphore.release();
break;
}
}
}
}

class MockConnection implements Connection {

private String name;

public MockConnection(String name) {
this.name = name;
}

@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}

// 一些重写的方法
}
6、更深入理解
1、单独使用Semaphore是不会使用到AQS的条件队列的

不同于CyclicBarrier和ReentrantLock,单独使用Semaphore是不会使用到AQS的条件队列的,其实,只有进行await操作才会进入条件队列,其他的都是在同步队列中,只是当前线程会被park。

2、场景问题——semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?

答案:拿不到令牌的线程阻塞,不会继续往下运行。

3、场景问题——semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?

答案:线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。(这和你一次性申请11个令牌是一样的)

4、场景问题——semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?

答案:能,原因是release方法会添加令牌,并不会以初始化的大小为准。

5、场景问题——semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?

答案:能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。

具体示例如下,如果不相信的话,可以运行一下下面的demo,在做实验之前,笔者也认为应该是不允许的。。(或许是开发者考虑不周到)

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TestSemaphore2 {
public static void main(String[] args) {
int permitsNum = 2;
final Semaphore semaphore = new Semaphore(permitsNum);
try {
System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS));
semaphore.release();
System.out.println("availablePermits:"+semaphore.availablePermits()+",semaphore.tryAcquire(3,1, TimeUnit.SECONDS):"+semaphore.tryAcquire(3,1, TimeUnit.SECONDS));
}catch (Exception e) {

}
}
}

4、Phaser(移相器)

Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。

1、BAT大厂的面试问题
  • Phaser主要用来解决什么问题?
  • Phaser与CyclicBarrier和CountDownLatch的区别是什么?
  • 如果用CountDownLatch来实现Phaser的功能应该怎么实现?
  • Phaser运行机制是什么样的?
  • 给一个Phaser使用的示例?
2、Phaser运行机制

java-thread-x-juc-phaser-1

1、Registration(注册)

跟其他barrier不同,在phaser上注册的parties会随着时间的变化而变化。任务可以随时注册(使用方法register,bulkRegister注册,或者由构造器确定初始parties),并且在任何抵达点可以随意地撤销注册(方法arriveAndDeregister)。就像大多数基本的同步结构一样,注册和撤销只影响内部count;不会创建更深的内部记录,所以任务不能查询他们是否已经注册。(不过,可以通过继承来实现类似的记录)

2、Synchronization(同步机制)

和CyclicBarrier一样,Phaser也可以重复await。方法arriveAndAwaitAdvance的效果类似CyclicBarrier.await。phaser的每一代都有一个相关的phase number,初始值为0,当所有注册的任务都到达phaser时phase+1,到达最大值(Integer.MAX_VALUE)之后清零。使用phase number可以独立控制 ==到达phaser== 和 ==等待其他线程== 的动作,通过下面两种类型的方法:

  • Arrival(到达机制) arrive和arriveAndDeregister方法记录到达状态。这些方法不会阻塞,但是会返回一个相关的arrival phase number;也就是说,phase number用来确定到达状态。当所有任务都到达给定phase时,可以执行一个可选的函数,这个函数通过重写onAdvance方法实现,通常可以用来控制终止状态。重写此方法类似于为CyclicBarrier提供一个barrierAction,但比它更灵活。
  • Waiting(等待机制) awaitAdvance方法需要一个表示arrival phase number的参数,并且在phaser前进到与给定phase不同的phase时返回。和CyclicBarrier不同,即使等待线程已经被中断,awaitAdvance方法也会一直等待。中断状态和超时时间同样可用,但是当任务等待中断或超时后未改变phaser的状态时会遭遇异常。如果有必要,在方法forceTermination之后可以执行这些异常的相关的handler进行恢复操作,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务
3、Termination(终止机制)

可以用isTerminated方法检查phaser的终止状态。在终止时,所有同步方法立刻返回一个负值。在终止时尝试注册也没有效果。当调用onAdvance返回true时Termination被触发。当deregistration操作使已注册的parties变为0时,onAdvance的默认实现就会返回true。也可以重写onAdvance方法来定义终止动作。forceTermination方法也可以释放等待线程并且允许它们终止。

4、Tiering(分层结构)

Phaser支持分层结构(树状构造)来减少竞争。注册了大量parties的Phaser可能会因为同步竞争消耗很高的成本, 因此可以设置一些子Phaser来共享一个通用的parent。这样的话即使每个操作消耗了更多的开销,但是会提高整体吞吐量。 在一个分层结构的phaser里,子节点phaser的注册和取消注册都通过父节点管理。子节点phaser通过构造或方法register、bulkRegister进行首次注册时,在其父节点上注册。子节点phaser通过调用arriveAndDeregister进行最后一次取消注册时,也在其父节点上取消注册。

5、Monitoring(状态监控)

由于同步方法可能只被已注册的parties调用,所以phaser的当前状态也可能被任何调用者监控。在任何时候,可以通过getRegisteredParties获取parties数,其中getArrivedParties方法返回已经到达当前phase的parties数。当剩余的parties(通过方法getUnarrivedParties获取)到达时,phase进入下一代。这些方法返回的值可能只表示短暂的状态,所以一般来说在同步结构里并没有啥卵用。

3、Phaser源码详解
1、核心参数
1
2
3
4
5
6
7
8
9
10
11
12
private volatile long state;
/**
* The parent of this phaser, or null if none
*/
private final Phaser parent;
/**
* The root of phaser tree. Equals this if not in a tree.
*/
private final Phaser root;
//等待线程的栈顶元素,根据phase取模定义为一个奇数header和一个偶数header
private final AtomicReference<QNode> evenQ;
private final AtomicReference<QNode> oddQ;

state状态说明:Phaser使用一个long型state值来标识内部状态:

  • 低0-15位表示未到达parties数
  • 中16-31位表示等待的parties数
  • 中32-62位表示phase当前代
  • 高63位表示当前phaser的终止状态

注意:子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。这里在后面源码分析的reconcileState方法里会讲解。 Qnode是Phaser定义的内部等待队列,用于在阻塞时记录等待线程及相关信息。实现了ForkJoinPool的一个内部接口ManagedBlocker,上面已经说过,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务(通过内部实现方法isReleasable和block)。

2、函数列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//构造方法
public Phaser() {
this(null, 0);
}
public Phaser(int parties) {
this(null, parties);
}
public Phaser(Phaser parent) {
this(parent, 0);
}
public Phaser(Phaser parent, int parties)
//注册一个新的party
public int register()
//批量注册
public int bulkRegister(int parties)
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive()
//使当前线程到达phaser并撤销注册,返回arrival phase number
public int arriveAndDeregister()
/*
* 使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。
* 如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。
* 如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。
*/
public int arriveAndAwaitAdvance()
//等待给定phase数,返回下一个 arrival phase number
public int awaitAdvance(int phase)
//阻塞等待,直到phase前进到下一代,返回下一代的phase number
public int awaitAdvance(int phase)
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase) throws InterruptedException
public int awaitAdvanceInterruptibly(int phase, long timeout, TimeUnit unit)
throws InterruptedException, TimeoutException
//使当前phaser进入终止状态,已注册的parties不受影响,如果是分层结构,则终止所有phaser
public void forceTermination()
3、方法——register()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//注册一个新的party
public int register() {
return doRegister(1);
}
private int doRegister(int registrations) {
// adjustment to state
long adjust = ((long)registrations << PARTIES_SHIFT) | registrations;
final Phaser parent = this.parent;
int phase;
for (;;) {
long s = (parent == null) ? state : reconcileState();
int counts = (int)s;
int parties = counts >>> PARTIES_SHIFT;//获取已注册parties数
int unarrived = counts & UNARRIVED_MASK;//未到达数
if (registrations > MAX_PARTIES - parties)
throw new IllegalStateException(badRegister(s));
phase = (int)(s >>> PHASE_SHIFT);//获取当前代
if (phase < 0)
break;
if (counts != EMPTY) { // not 1st registration
if (parent == null || reconcileState() == s) {
if (unarrived == 0) // wait out advance
root.internalAwaitAdvance(phase, null);//等待其他任务到达
else if (UNSAFE.compareAndSwapLong(this, stateOffset,
s, s + adjust))//更新注册的parties数
break;
}
}
else if (parent == null) { // 1st root registration
long next = ((long)phase << PHASE_SHIFT) | adjust;
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, next))//更新phase
break;
}
else {
//分层结构,子phaser首次注册用父节点管理
synchronized (this) { // 1st sub registration
if (state == s) { // recheck under lock
phase = parent.doRegister(1);//分层结构,使用父节点注册
if (phase < 0)
break;
// finish registration whenever parent registration
// succeeded, even when racing with termination,
// since these are part of the same "transaction".
//由于在同一个事务里,即使phaser已终止,也会完成注册
while (!UNSAFE.compareAndSwapLong
(this, stateOffset, s,
((long)phase << PHASE_SHIFT) | adjust)) {//更新phase
s = state;
phase = (int)(root.state >>> PHASE_SHIFT);
// assert (int)s == EMPTY;
}
break;
}
}
}
}
return phase;
}

说明:register方法为phaser添加一个新的party,如果onAdvance正在运行,那么这个方法会等待它运行结束再返回结果。如果当前phaser有父节点,并且当前phaser上没有已注册的party,那么就会交给父节点注册。

register和bulkRegister都由doRegister实现,大概流程如下:

  • 如果当前操作不是首次注册,那么直接在当前phaser上更新注册parties数

  • 如果是首次注册,并且当前phaser没有父节点,说明是root节点注册,直接更新phase

  • 如果当前操作是首次注册,并且当前phaser由父节点,则注册操作交由父节点,并更新当前phaser的phase

  • 上面说过,子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。非首次注册时,如果Phaser有父节点,则调用reconcileState()方法解决root节点的phase延迟传递问题, 源码如下:

    • private long reconcileState() {
          final Phaser root = this.root;
          long s = state;
          if (root != this) {
              int phase, p;
              // CAS to root phase with current parties, tripping unarrived
              while ((phase = (int)(root.state >>> PHASE_SHIFT)) !=
                     (int)(s >>> PHASE_SHIFT) &&
                     !UNSAFE.compareAndSwapLong
                     (this, stateOffset, s,
                      s = (((long)phase << PHASE_SHIFT) |
                           ((phase < 0) ? (s & COUNTS_MASK) :
                            (((p = (int)s >>> PARTIES_SHIFT) == 0) ? EMPTY :
                             ((s & PARTIES_MASK) | p))))))
                  s = state;
          }
          return s;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57

      - 当root节点的phase已经advance到下一代,但是子节点phaser还没有,这种情况下它们必须通过更新未到达parties数完成它们自己的advance操作(如果parties为0,重置为EMPTY状态)。

      - 回到register方法的第一步,如果当前未到达数为0,说明上一代phase正在进行到达操作,此时调用internalAwaitAdvance()方法等待其他任务完成到达操作,源码如下:

      - ```java
      //阻塞等待phase到下一代
      private int internalAwaitAdvance(int phase, QNode node) {
      // assert root == this;
      releaseWaiters(phase-1); // ensure old queue clean
      boolean queued = false; // true when node is enqueued
      int lastUnarrived = 0; // to increase spins upon change
      int spins = SPINS_PER_ARRIVAL;
      long s;
      int p;
      while ((p = (int)((s = state) >>> PHASE_SHIFT)) == phase) {
      if (node == null) { // spinning in noninterruptible mode
      int unarrived = (int)s & UNARRIVED_MASK;//未到达数
      if (unarrived != lastUnarrived &&
      (lastUnarrived = unarrived) < NCPU)
      spins += SPINS_PER_ARRIVAL;
      boolean interrupted = Thread.interrupted();
      if (interrupted || --spins < 0) { // need node to record intr
      //使用node记录中断状态
      node = new QNode(this, phase, false, false, 0L);
      node.wasInterrupted = interrupted;
      }
      }
      else if (node.isReleasable()) // done or aborted
      break;
      else if (!queued) { // push onto queue
      AtomicReference<QNode> head = (phase & 1) == 0 ? evenQ : oddQ;
      QNode q = node.next = head.get();
      if ((q == null || q.phase == phase) &&
      (int)(state >>> PHASE_SHIFT) == phase) // avoid stale enq
      queued = head.compareAndSet(q, node);
      }
      else {
      try {
      ForkJoinPool.managedBlock(node);//阻塞给定node
      } catch (InterruptedException ie) {
      node.wasInterrupted = true;
      }
      }
      }

      if (node != null) {
      if (node.thread != null)
      node.thread = null; // avoid need for unpark()
      if (node.wasInterrupted && !node.interruptible)
      Thread.currentThread().interrupt();
      if (p == phase && (p = (int)(state >>> PHASE_SHIFT)) == phase)
      return abortWait(phase); // possibly clean up on abort
      }
      releaseWaiters(phase);
      return p;
      }
  • 简单介绍下第二个参数node,如果不为空,则说明等待线程需要追踪中断状态或超时状态。以doRegister中的调用为例,不考虑线程争用,internalAwaitAdvance大概流程如下:

    • 首先调用releaseWaiters唤醒上一代所有等待线程,确保旧队列中没有遗留的等待线程。
    • 循环SPINS_PER_ARRIVAL指定的次数或者当前线程被中断,创建node记录等待线程及相关信息。
    • 继续循环调用ForkJoinPool.managedBlock运行被阻塞的任务
    • 继续循环,阻塞任务运行成功被释放,跳出循环
    • 最后唤醒当前phase的线程
4、方法——arrive()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive() {
return doArrive(ONE_ARRIVAL);
}

private int doArrive(int adjust) {
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
//获取未到达数
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, s-=adjust)) {//更新state
if (unarrived == 1) {//当前为最后一个未到达的任务
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (root == this) {
if (onAdvance(phase, nextUnarrived))//检查是否需要终止phaser
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;
n |= (long)nextPhase << PHASE_SHIFT;
UNSAFE.compareAndSwapLong(this, stateOffset, s, n);
releaseWaiters(phase);//释放等待phase的线程
}
//分层结构,使用父节点管理arrive
else if (nextUnarrived == 0) { //propagate deregistration
phase = parent.doArrive(ONE_DEREGISTER);
UNSAFE.compareAndSwapLong(this, stateOffset,
s, s | EMPTY);
}
else
phase = parent.doArrive(ONE_ARRIVAL);
}
return phase;
}
}
}

说明:arrive方法手动调整到达数,使当前线程到达phaser。arrive和arriveAndDeregister都调用了doArrive实现,大概流程如下:

  • 首先更新state(state - adjust);
  • 如果当前不是最后一个未到达的任务,直接返回phase
  • 如果当前是最后一个未到达的任务:
    • 如果当前是root节点,判断是否需要终止phaser,CAS更新phase,最后释放等待的线程;
    • 如果是分层结构,并且已经没有下一代未到达的parties,则交由父节点处理doArrive逻辑,然后更新state为EMPTY。
5、方法——arriveAndAwaitAdvance()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public int arriveAndAwaitAdvance() {
// Specialization of doArrive+awaitAdvance eliminating some reads/paths
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);//获取未到达数
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s,
s -= ONE_ARRIVAL)) {//更新state
if (unarrived > 1)
return root.internalAwaitAdvance(phase, null);//阻塞等待其他任务
if (root != this)
return parent.arriveAndAwaitAdvance();//子Phaser交给父节点处理
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (onAdvance(phase, nextUnarrived))//全部到达,检查是否可销毁
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;//计算下一代phase
n |= (long)nextPhase << PHASE_SHIFT;
if (!UNSAFE.compareAndSwapLong(this, stateOffset, s, n))//更新state
return (int)(state >>> PHASE_SHIFT); // terminated
releaseWaiters(phase);//释放等待phase的线程
return nextPhase;
}
}
}

说明:使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。效果类似于CyclicBarrier.await。大概流程如下:

  • 更新state(state - 1);
  • 如果未到达数大于1,调用internalAwaitAdvance阻塞等待其他任务到达,返回当前phase
  • 如果为分层结构,则交由父节点处理arriveAndAwaitAdvance逻辑
  • 如果未到达数<=1,判断phaser终止状态,CAS更新phase到下一代,最后释放等待当前phase的线程,并返回下一代phase。
6、方法——awaitAdvance(int phase)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public int awaitAdvance(int phase) {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase)
return root.internalAwaitAdvance(phase, null);
return p;
}
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase)
throws InterruptedException {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase) {
QNode node = new QNode(this, phase, true, false, 0L);
p = root.internalAwaitAdvance(phase, node);
if (node.wasInterrupted)
throw new InterruptedException();
}
return p;
}

说明:awaitAdvance用于阻塞等待线程到达,直到phase前进到下一代,返回下一代的phase number。方法很简单,不多赘述。awaitAdvanceInterruptibly方法是响应中断版的awaitAdvance,不同之处在于,调用阻塞时会记录线程的中断状态。

5、Exchanger(交换器)

Exchanger是用于线程协作的工具类,主要用于两个线程之间的数据交换

1、BAT大厂的面试问题
  • Exchanger主要解决什么问题?
  • 对比SynchronousQueue,为什么说Exchanger可被视为 SynchronousQueue 的双向形式?
  • Exchanger在不同的JDK版本中实现有什么差别?
  • Exchanger实现机制?
  • Exchanger已经有了slot单节点,为什么会加入arena node数组?什么时候会用到数组?
  • arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
  • 什么是伪共享,Exchanger中如何体现的?
  • Exchanger实现举例
2、Exchanger简介

Exchanger用于进行两个线程之间的数据交换。它提供一个==同步点==,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。

3、Exchanger实现机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for (;;) {
if (slot is empty) { // offer
// slot为空时,将item 设置到Node 中
place item in a Node;
if (can CAS slot from empty to node) {
// 当将node通过CAS交换到slot中时,挂起线程等待被唤醒
wait for release;
// 被唤醒后返回node中匹配到的item
return matching item in node;
}
} else if (can CAS slot from node to empty) { // release
// 将slot设置为空
// 获取node中的item,将需要交换的数据设置到匹配的item
get the item in node;
set matching item in node;
// 唤醒等待的线程
release waiting thread;
}
// else retry on CAS failure
}

比如有2条线程A和B,A线程交换数据时,发现slot为空,则将需要交换的数据放在slot中等待其它线程进来交换数据,等线程B进来,读取A设置的数据,然后设置线程B需要交换的数据,然后唤醒A线程,原理就是这么简单。但是当多个线程之间进行交换数据时就会出现问题,所以Exchanger加入了arena数组

4、Exchanger源码解析
1、内部类——Participant
1
2
3
static final class Participant extends ThreadLocal<Node> {
public Node initialValue() { return new Node(); }
}

Participant的作用是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,说明每个线程具有不同的状态。

2、内部类——Node
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@sun.misc.Contended static final class Node {
// arena的下标,多个槽位的时候利用
int index;
// 上一次记录的Exchanger.bound
int bound;
// 在当前bound下CAS失败的次数;
int collides;
// 用于自旋;
int hash;
// 这个线程的当前项,也就是需要交换的数据;
Object item;
//做releasing操作的线程传递的项;
volatile Object match;
//挂起时设置线程值,其他情况下为null;
volatile Thread parked;
}

在Node定义中有两个变量值得思考:bound以及collides。前面提到了数组area是为了避免竞争而产生的,如果系统不存在竞争问题,那么完全没有必要开辟一个高效的arena来徒增系统的复杂性

  1. 首先通过单个slot的exchanger来交换数据,当探测到竞争时将安排不同的位置的slot来保存线程Node,并且可以确保没有slot会在同一个缓存行上。
  2. 如何来判断会有竞争呢?
    • CAS替换slot失败,如果失败,则通过记录冲突次数来扩展arena的尺寸,我们在记录冲突的过程中会跟踪“bound”的值,以及会重新计算在bound的值被改变时的冲突次数。
3、核心属性
1
2
3
private final Participant participant;
private volatile Node[] arena;
private volatile Node slot;
  • **为什么会有 arena数组槽**?

    • slot为单个槽,arena为数组槽,他们都是Node类型。
    • 在这里可能会感觉到疑惑,slot作为Exchanger交换数据的场景,应该只需要一个就可以了啊?为何还多了一个Participant和数组类型的arena呢?
    • 一个slot交换场所原则上来说应该是可以的,但实际情况却不是如此,多个参与者使用同一个交换场所时,会存在严重==伸缩性问题==。既然单个交换场所存在问题,那么我们就安排多个,也就是数组arena。
    • 通过数组arena来安排不同的线程使用不同的slot来降低竞争问题,并且可以保证最终一定会成对交换数据。但是**Exchanger不是一来就会生成arena数组来降低竞争,==只有当产生竞争是才会生成arena数组==**。
  • 那么怎么将Node与当前线程绑定呢?

    • Participant,Participant 的作用就是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,同时在Node节点中记录在arena中的下标index。
4、构造函数
1
2
3
4
5
6
/**
* Creates a new Exchanger.
*/
public Exchanger() {
participant = new Participant();
}

初始化participant对象。

5、核心方法——exchange(V x)

等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。

1
2
3
4
5
6
7
8
9
10
11
12
public V exchange(V x) throws InterruptedException {
Object v;
// 当参数为null时需要将item设置为空的对象
Object item = (x == null) ? NULL_ITEM : x; // translate null args
// 注意到这里的这个表达式是整个方法的核心
if ((arena != null ||
(v = slotExchange(item, false, 0 L)) == null) &&
((Thread.interrupted() || // disambiguates null return
(v = arenaExchange(item, false, 0 L)) == null)))
throw new InterruptedException();
return (v == NULL_ITEM) ? null : (V) v;
}

这个方法比较好理解:

  • arena为数组槽,如果为null,则执行slotExchange()方法,
  • 否则判断线程是否中断,如果中断值抛出InterruptedException异常,
  • 没有中断则执行arenaExchange()方法。

整套逻辑就是:如果slotExchange(Object item, boolean timed, long ns)方法执行失败了就执行arenaExchange(Object item, boolean timed, long ns)方法,最后返回结果V。

NULL_ITEM 为一个空节点,其实就是一个Object对象而已,slotExchange()为单个slot交换

6、slotExchange(Object item, boolean timed, long ns)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
private final Object slotExchange(Object item, boolean timed, long ns) {
// 获取当前线程node对象
Node p = participant.get();
// 当前线程
Thread t = Thread.currentThread();
// 若果线程被中断,就直接返回null
if (t.isInterrupted()) // preserve interrupt status so caller can recheck
return null;
// 自旋
for (Node q;;) {
// 将slot值赋给q
if ((q = slot) != null) {
// slot 不为null,即表示已有线程已经把需要交换的数据设置在slot中了
// 通过CAS将slot设置成null
if (U.compareAndSwapObject(this, SLOT, q, null)) {
// CAS操作成功后,将slot中的item赋值给对象v,以便返回。
// 这里也是就读取之前线程要交换的数据
Object v = q.item;
// 将当前线程需要交给的数据设置在q中的match
q.match = item;
// 获取被挂起的线程
Thread w = q.parked;
if (w != null)
// 如果线程不为null,唤醒它
U.unpark(w);
// 返回其他线程给的V
return v;
}
// create arena on contention, but continue until slot null
// CAS 操作失败,表示有其它线程竞争,在此线程之前将数据已取走
// NCPU:CPU的核数
// bound == 0 表示arena数组未初始化过,CAS操作bound将其增加SEQ
if (NCPU > 1 && bound == 0 &&
U.compareAndSwapInt(this, BOUND, 0, SEQ))
// 初始化arena数组
arena = new Node[(FULL + 2) << ASHIFT];
}
// 上面分析过,只有当arena不为空才会执行slotExchange方法的
// 所以表示刚好已有其它线程加入进来将arena初始化
else if (arena != null)
// 这里就需要去执行arenaExchange
return null; // caller must reroute to arenaExchange
else {
// 这里表示当前线程是以第一个线程进来交换数据
// 或者表示之前的数据交换已进行完毕,这里可以看作是第一个线程
// 将需要交换的数据先存放在当前线程变量p中
p.item = item;
// 将需要交换的数据通过CAS设置到交换区slot
if (U.compareAndSwapObject(this, SLOT, null, p))
// 交换成功后跳出自旋
break;
// CAS操作失败,表示有其它线程刚好先于当前线程将数据设置到交换区slot
// 将当前线程变量中的item设置为null,然后自旋获取其它线程存放在交换区slot的数据
p.item = null;
}
}

// await release
// 执行到这里表示当前线程已将需要的交换的数据放置于交换区slot中了,
// 等待其它线程交换数据然后唤醒当前线程
int h = p.hash;
long end = timed ? System.nanoTime() + ns : 0 L;
// 自旋次数
int spins = (NCPU > 1) ? SPINS : 1;
Object v;
// 自旋等待直到p.match不为null,也就是说等待其它线程将需要交换的数据放置于交换区slot
while ((v = p.match) == null) {
// 下面的逻辑主要是自旋等待,直到spins递减到0为止
if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10;
if (h == 0)
h = SPINS | (int) t.getId();
else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield();
} else if (slot != p)
spins = SPINS;
// 此处表示未设置超时或者时间未超时
else if (!t.isInterrupted() && arena == null &&
(!timed || (ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this);
// 给p挂机线程的值赋值
p.parked = t;
if (slot == p)
// 如果slot还没有被置为null,也就表示暂未有线程过来交换数据,需要将当前线程挂起
U.park(false, ns);
// 线程被唤醒,将被挂起的线程设置为null
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
// 不是以上条件时(可能是arena已不为null或者超时)
} else if (U.compareAndSwapObject(this, SLOT, p, null)) {
// arena不为null则v为null,其它为超时则v为超市对象TIMED_OUT,并且跳出循环
v = timed && ns <= 0 L && !t.isInterrupted() ? TIMED_OUT : null;
break;
}
}
// 取走match值,并将p中的match置为null
U.putOrderedObject(p, MATCH, null);
// 设置item为null
p.item = null;
p.hash = h;
// 返回交换值
return v;
}

程序首先通过participant获取当前线程节点Node。检测是否中断,如果中断return null,等待后续抛出InterruptedException异常。

  • 如果slot不为null,则进行slot消除,成功直接返回数据V,否则失败,则创建arena消除数组。
  • 如果slot为null,但arena不为null,则返回null,进入arenaExchange逻辑。
  • 如果slot为null,且arena也为null,则尝试占领该slot,失败重试,成功则跳出循环进入spin+block(自旋+阻塞)模式。

在自旋+阻塞模式中,首先取得结束时间和自旋次数

  • 如果match(做releasing操作的线程传递的项)为null,其首先尝试spins+随机次自旋(改自旋使用当前节点中的hash,并改变之)和退让。
  • 当自旋数为0后,假如slot发生了改变(slot != p)则重置自旋数并重试。
  • 否则
    • 假如:当前未中断&arena为null&(当前不是限时版本或者限时版本+当前时间未结束):阻塞或者限时阻塞。
    • 假如:当前中断或者arena不为null或者当前为限时版本+时间已经结束:
      • 不限时版本:置v为null;
      • 限时版本:如果时间结束以及未中断则TIMED_OUT;
  • 否则给出null(原因是探测到arena非空或者当前线程中断)。
  • match不为空时跳出循环。
7、arenaExchange(Object item, boolean timed, long ns)

此方法被执行时表示多个线程进入交换区交换数据,arena数组已被初始化,此方法中的一些处理方式和slotExchange比较类似,它是通过遍历arena数组找到需要交换的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// timed 为true表示设置了超时时间,ns为>0的值,反之没有设置超时时间
private final Object arenaExchange(Object item, boolean timed, long ns) {
Node[] a = arena;
// 获取当前线程中的存放的node
Node p = participant.get();
//index初始值0
for (int i = p.index;;) { // access slot at i
// 遍历,如果在数组中找到数据则直接交换并唤醒线程,如未找到则将需要交换给其它线程的数据放置于数组中
int b, m, c;
long j; // j is raw array offset
// 其实这里就是向右遍历数组,只是用到了元素在内存偏移的偏移量
// q实际为arena数组偏移(i + 1) * 128个地址位上的node
Node q = (Node) U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 如果q不为null,并且CAS操作成功,将下标j的元素置为null
if (q != null && U.compareAndSwapObject(a, j, q, null)) {
// 表示当前线程已发现有交换的数据,然后获取数据,唤醒等待的线程
Object v = q.item; // release
q.match = item;
Thread w = q.parked;
if (w != null)
U.unpark(w);
return v;
// q 为null 并且 i 未超过数组边界
} else if (i <= (m = (b = bound) & MMASK) && q == null) {
// 将需要给其它线程的item赋予给p中的item
p.item = item; // offer
if (U.compareAndSwapObject(a, j, null, p)) {
// 交换成功
long end = (timed && m == 0) ? System.nanoTime() + ns : 0 L;
Thread t = Thread.currentThread(); // wait
// 自旋直到有其它线程进入,遍历到该元素并与其交换,同时当前线程被唤醒
for (int h = p.hash, spins = SPINS;;) {
Object v = p.match;
if (v != null) {
// 其它线程设置的需要交换的数据match不为null
// 将match设置null,item设置为null
U.putOrderedObject(p, MATCH, null);
p.item = null; // clear for next use
p.hash = h;
return v;
} else if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10; // xorshift
if (h == 0) // initialize hash
h = SPINS | (int) t.getId();
else if (h < 0 && // approx 50% true
(--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield(); // two yields per wait
} else if (U.getObjectVolatile(a, j) != p)
// 和slotExchange方法中的类似,arena数组中的数据已被CAS设置
// match值还未设置,让其再自旋等待match被设置
spins = SPINS; // releaser hasn't set match yet
else if (!t.isInterrupted() && m == 0 &&
(!timed ||
(ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this); // emulate LockSupport
// 线程t赋值
p.parked = t; // minimize window
if (U.getObjectVolatile(a, j) == p)
// 数组中对象还相等,表示线程还未被唤醒,唤醒线程
U.park(false, ns);
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
} else if (U.getObjectVolatile(a, j) == p &&
U.compareAndSwapObject(a, j, p, null)) {
// 这里给bound增加加一个SEQ
if (m != 0) // try to shrink
U.compareAndSwapInt(this, BOUND, b, b + SEQ - 1);
p.item = null;
p.hash = h;
i = p.index >>>= 1; // descend
if (Thread.interrupted())
return null;
if (timed && m == 0 && ns <= 0 L)
return TIMED_OUT;
break; // expired; restart
}
}
} else
// 交换失败,表示有其它线程更改了arena数组中下标i的元素
p.item = null; // clear offer
} else {
// 此时表示下标不在bound & MMASK或q不为null但CAS操作失败
// 需要更新bound变化后的值
if (p.bound != b) { // stale; reset
p.bound = b;
p.collides = 0;
// 反向遍历
i = (i != m || m == 0) ? m : m - 1;
} else if ((c = p.collides) < m || m == FULL ||
!U.compareAndSwapInt(this, BOUND, b, b + SEQ + 1)) {
// 记录CAS失败的次数
p.collides = c + 1;
// 循环遍历
i = (i == 0) ? m : i - 1; // cyclically traverse
} else
// 此时表示bound值增加了SEQ+1
i = m + 1; // grow
// 设置下标
p.index = i;
}
}
}

首先通过participant取得当前节点Node,然后根据当前节点Node的index去取arena中相对应的节点node。

5、前面提到过arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
arena = new Node[(FULL + 2) << ASHIFT];
// 这个arena到底有多大呢? 我们先看FULL 和ASHIFT的定义:
static final int FULL = (NCPU >= (MMASK << 1)) ? MMASK : NCPU >>> 1;
private static final int ASHIFT = 7;

private static final int NCPU = Runtime.getRuntime().availableProcessors();
private static final int MMASK = 0xff; // 255
// 假如我的机器NCPU = 8 ,则得到的是768大小的arena数组。然后通过以下代码取得在arena中的节点:

Node q = (Node)U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 它仍然是通过右移ASHIFT位来取得Node的,ABASE定义如下:

Class<?> ak = Node[].class;
ABASE = U.arrayBaseOffset(ak) + (1 << ASHIFT);
// U.arrayBaseOffset获取对象头长度,数组元素的大小可以通过unsafe.arrayIndexScale(T[].class) 方法获取到。这也就是说要访问类型为T的第N个元素的话,你的偏移量offset应该是arrayOffset+N*arrayScale。也就是说BASE = arrayOffset+ 128 。
6、用@sun.misc.Contended来规避伪共享?

伪共享说明假设一个类的两个相互独立的属性a和b在内存地址上是连续的(比如FIFO队列的头尾指针),那么它们通常会被加载到相同的cpu cache line里面。并发情况下,如果一个线程修改了a,会导致整个cache line失效(包括b),这时另一个线程来读b,就需要从内存里再次加载了,这种多线程频繁修改ab的情况下,虽然a和b看似独立,但它们会互相干扰,非常影响性能。(在原子累加器篇也有伪共享问题的阐述)

我们再看Node节点的定义,在Java 8 中我们是可以利用sun.misc.Contended来规避伪共享的。所以说通过 << ASHIFT方式加上sun.misc.Contended,所以使得任意两个可用Node不会再同一个缓存行中。

1
2
3
@sun.misc.Contended static final class Node{
....
}

我们再次回到arenaExchange()。取得arena中的node节点后,如果定位的节点q 不为空,且CAS操作成功,则交换数据,返回交换的数据,唤醒等待的线程。

  • 如果q等于null且下标在bound & MMASK范围之内,则尝试占领该位置,如果成功,则采用自旋 + 阻塞的方式进行等待交换数据。
  • 如果下标不在bound & MMASK范围之内获取由于q不为null但是竞争失败的时候:消除p。加入bound 不等于当前节点的bond(b != p.bound),则更新p.bound = b,collides = 0 ,i = m或者m - 1。如果冲突的次数不到m 获取m 已经为最大值或者修改当前bound的值失败,则通过增加一次collides以及循环递减下标i的值;否则更新当前bound的值成功:我们令i为m+1即为此时最大的下标。最后更新当前index的值。
7、更深入理解
1、SynchronousQueue对比?

Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:

  • 线程A通过SynchronousQueue将数据a交给线程B;
  • 线程A通过Exchanger和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。

可见,SynchronousQueue是交给一个数据,Exchanger是交换两个数据。

2、不同JDK实现有何差别?
  • 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。
  • 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量
8、Exchanger示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Test {
static class Producer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Producer(String name, Exchanger<Integer> exchanger) {
super("Producer-" + name);
this.exchanger = exchanger;
}

@Override
public void run() {
for (int i=1; i<5; i++) {
try {
TimeUnit.SECONDS.sleep(1);
data = i;
System.out.println(getName()+" 交换前:" + data);
data = exchanger.exchange(data);
System.out.println(getName()+" 交换后:" + data);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

static class Consumer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Consumer(String name, Exchanger<Integer> exchanger) {
super("Consumer-" + name);
this.exchanger = exchanger;
}

@Override
public void run() {
while (true) {
data = 0;
System.out.println(getName()+" 交换前:" + data);
try {
TimeUnit.SECONDS.sleep(1);
data = exchanger.exchange(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName()+" 交换后:" + data);
}
}
}

public static void main(String[] args) throws InterruptedException {
Exchanger<Integer> exchanger = new Exchanger<Integer>();
new Producer("", exchanger).start();
new Consumer("", exchanger).start();
TimeUnit.SECONDS.sleep(7);
System.exit(-1);
}
}

可以看到,其结果可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Consumer- 交换前:0
Producer- 交换前:1
Consumer- 交换后:1
Consumer- 交换前:0
Producer- 交换后:0
Producer- 交换前:2
Producer- 交换后:0
Consumer- 交换后:2
Consumer- 交换前:0
Producer- 交换前:3
Producer- 交换后:0
Consumer- 交换后:3
Consumer- 交换前:0
Producer- 交换前:4
Producer- 交换后:0
Consumer- 交换后:4
Consumer- 交换前:0

16、ThreadPool线程池

1、线程池简介

线程池(英语:thread pool):一种线程使用模式。线程过多会带来调度开销, 进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。

2、线程池的优势

线程池做的工作只要是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量, 超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。

3、线程池的主要特点

  • 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的销耗。
  • 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会销耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

Java 中的线程池是通过 Executor 框架实现的,该框架中用到了 ExecutorExecutorsExecutorServiceThreadPoolExecutor 这几个类:

image-20210728133227349

4、线程池参数说明

  • corePoolSize:线程池的核心线程数
  • maximumPoolSize:能容纳的最大线程数
  • keepAliveTime:空闲线程存活时间
  • unit:存活的时间单位
  • workQueue:存放提交但未执行任务的队列
  • threadFactory:创建线程的工厂类
  • handler:等待队列满后的拒绝策略

image-20210728134755032

5、拒绝策略(重点)

线程池中,有三个重要的参数,决定影响了拒绝策略:corePoolSize - 核心线程数,也即最小的线程数。workQueue - 阻塞队列 。 maximumPoolSize -最大线程数。

当提交任务数大于 corePoolSize 的时候,会优先将任务放到 workQueue 阻塞队列中。当阻塞队列饱和后,会扩充线程池中线程数,直到达到maximumPoolSize 最大线程数配置。此时,再多余的任务,则会触发线程池的拒绝策略了。

总结起来,也就是一句话,当提交的任务数大于(workQueue.size() + maximumPoolSize ),就会触发线程池的拒绝策略。

四种拒绝策略:

  • CallerRunsPolicy:当触发拒绝策略,只要线程池没有关闭的话,则使用调用线程直接运行任务。一般并发比较小,性能要求不高,不允许失败。但是,由于调用者自己运行任务,如果任务提交速度过快,可能导致程序阻塞,性能效率上必然的损失较大;
  • AbortPolicy:丢弃任务,并抛出拒绝执行 RejectedExecutionException 异常信息。线程池默认的拒绝策略。必须处理好抛出的异常,否则会打断当前的执行流程,影响后续的任务执行。
  • DiscardPolicy:直接丢弃,其他啥都没有;
  • DiscardOldestPolicy:当触发拒绝策略,只要线程池没有关闭的话,丢弃阻塞队列 workQueue 中最老的一个任务,并将新任务加入

除了线程池给的四种拒绝策略的实现,其他著名框架也提供了拒绝策略实现:

  • Dubbo 的实现:在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息方便定位问题
  • Netty 的实现:创建一个新线程来执行任务
  • ActiveMQ 的实现:带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
  • PinPoint 的实现:它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略

6、线程池的种类与创建

  • newCachedThreadPool——线程池根据需求创建线程,可扩容,遇强则强

    • 作用:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

    • 特点:

      • 线程池中数量没有固定,可达到最大值(Interger. MAX_VALUE)
      • 线程池中的线程可进行缓存重复利用和回收(回收默认时间为 1 分钟)
      • 当线程池中,没有可用线程,会重新创建一个线程
    • 创建方式:

      • /**
          *    可缓存线程池
          * @return
          */
        public static ExecutorService newCachedThreadPool(){
            /**
            *    corePoolSize 线程池的核心线程数
            *    maximumPoolSize 能容纳的最大线程数
            *    keepAliveTime 空闲线程存活时间
            *    unit 存活的时间单位
            *    workQueue 存放提交但未执行任务的队列
            *    threadFactory 创建线程的工厂类:可以省略
            *    handler 等待队列满后的拒绝策略:可以省略
            */ 
            return new ThreadPoolExecutor(0, 
                   Integer.MAX_VALUE, 
                   60L,                        
                   TimeUnit.SECONDS,                        
                   new SynchronousQueue<>(),                        
                   Executors.defaultThreadFactory(),                       
                   new ThreadPoolExecutor.AbortPolicy());
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38

        - 场景:适用于创建一个可无限扩大的线程池,服务器负载压力较轻,执行时间较短,任务多的场景

        - newFixedThreadPool——一池N线程

        - 作用:创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

        - 特点:

        - 线程池中的线程处于一定的量,可以很好的控制线程的并发量
        - 线程可以重复被使用,在显示关闭之前,都将一直存在
        - 超出一定量的线程被提交时候需在队列中等待

        - 创建方式:

        - ```java
        /**
        * 固定长度线程池
        * @return
        */
        public static ExecutorService newCachedThreadPool(){
        /**
        * corePoolSize 线程池的核心线程数
        * maximumPoolSize 能容纳的最大线程数
        * keepAliveTime 空闲线程存活时间
        * unit 存活的时间单位
        * workQueue 存放提交但未执行任务的队列
        * threadFactory 创建线程的工厂类:可以省略
        * handler 等待队列满后的拒绝策略:可以省略
        */
        return new ThreadPoolExecutor(10,
        Integer.MAX_VALUE,
        0L,
        TimeUnit.SECONDS,
        new SynchronousQueue<>(),
        Executors.defaultThreadFactory(),
        new ThreadPoolExecutor.AbortPolicy());
        }
    • 场景:适用于可以预测线程数量的业务中,或者服务器负载较重,对线程数有严格限制的场景

  • newSingleThreadExecutor——一个任务一个任务执行,一池一线程

    • 作用:创建一个使用单个 worker 线程的 Executor,以无界队列方式来运行该线程。(注意,如果因为在关闭前的执行期间出现失败而终止了此单个线程, 那么如果需要,一个新线程将代替它执行后续的任务)。可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。与其他等效的newFixedThreadPool 不同,可保证无需重新配置此方法所返回的执行程序即可使用其他的线程。

    • 特点:线程池中最多执行 1 个线程,之后提交的线程活动将会排在队列中以此执行

    • 创建方式:

      • /**
          *    单一线程池
          * @return
          */
        public static ExecutorService newCachedThreadPool(){
            /**
            *    corePoolSize 线程池的核心线程数
            *    maximumPoolSize 能容纳的最大线程数
            *    keepAliveTime 空闲线程存活时间
            *    unit 存活的时间单位
            *    workQueue 存放提交但未执行任务的队列
            *    threadFactory 创建线程的工厂类:可以省略
            *    handler 等待队列满后的拒绝策略:可以省略
            */ 
            return new ThreadPoolExecutor(1, 
                   1, 
                   0L,                        
                   TimeUnit.SECONDS,                        
                   new SynchronousQueue<>(),                        
                   Executors.defaultThreadFactory(),                       
                   new ThreadPoolExecutor.AbortPolicy());
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19

        - 场景:适用于需要保证顺序执行各个任务,并且在任意时间点,不会同时有多个线程的场景

        - newScheduleThreadPool(了解)——定时以及周期性执行任务线程池

        - 作用:线程池支持定时以及周期性执行任务,创建一个 corePoolSize 为传入参数,最大线程数为整形的最大数的线程池

        - 特点:

        - 线程池中具有指定数量的线程,即便是空线程也将保留
        - 可定时或者延迟执行线程活动

        - 创建方式:

        - ```java
        public static ScheduledExecutorService newScheduledThreadPool
        (int corePoolSize, ThreadFactory threadFactory) {
        return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
        }
    • 场景:适用于需要多个后台线程执行周期任务的场景

  • newWorkStealingPool——多个任务队列的线程池

    • dk1.8 提供的线程池,底层使用的是 ForkJoinPool 实现,创建一个拥有多个任务队列的线程池,可以减少连接数,创建当前可用 cpu 核数的线程来并行执行任务

    • 创建方式:

      • public static ExecutorService newWorkStealingPool(int parallelism) {
            /**
            *    parallelism:并行级别,通常默认为 JVM 可用的处理器个数
            *    factory:用于创建 ForkJoinPool 中使用的线程。
            *    handler:用于处理工作线程未处理的异常,默认为 null
            *    asyncMode:用于控制 WorkQueue 的工作模式:队列---反队列
            */ 
            return new ForkJoinPool(parallelism, 
                                    ForkJoinPool.defaultForkJoinWorkerThreadFactory, 
                                    null, 
                                    true);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        81
        82
        83
        84
        85
        86
        87
        88
        89
        90
        91
        92
        93
        94
        95
        96
        97
        98
        99
        100
        101
        102
        103
        104
        105
        106
        107
        108
        109
        110
        111
        112
        113
        114
        115
        116
        117
        118
        119
        120
        121
        122
        123
        124
        125
        126
        127
        128
        129
        130
        131
        132
        133
        134
        135
        136
        137
        138
        139
        140
        141
        142
        143
        144
        145
        146
        147
        148
        149
        150
        151
        152
        153
        154
        155
        156
        157
        158
        159
        160
        161
        162
        163
        164
        165
        166
        167
        168
        169
        170
        171
        172
        173
        174
        175
        176
        177
        178
        179
        180
        181
        182
        183
        184
        185
        186
        187

        - 场景:适用于大耗时,可并行执行的场景



        #### 7、线程池底层的工作原理

        ![10-线程池底层工作流程](JUC/10-线程池底层工作流程.png)

        1. 在创建了线程池后,线程池中的线程数为零,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
        2. 当调用 execute()方法添加一个请求任务时,线程池会做出如下判断:
        1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
        2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入workQueue 队列;
        3. 如果这个时候队列满了且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务(救急);
        4. 如果队列满了且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会启动饱和拒绝策略来执行。
        3. 当一个线程完成任务时,它会从队列中取下一个任务来执行
        4. 当一个线程无事可做超过一定的时间(keepAliveTime)时,线程会判断:
        1. 如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。
        2. 所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

        ![image-20210728141353731](JUC/image-20210728141353731.png)



        #### 8、线程池的注意事项

        1. **线程的创建不是在创建线程池的时候创建,而是在执行execute()方法的时候,线程才真正地开始创建**;
        2. 项目中创建多线程时,使用常见的三种线程池创建方式,`单一`、`可变`、`定长`。但是它们都有一定问题,原因是 `FixedThreadPool` 和 `SingleThreadExecutor` 底层都是用LinkedBlockingQueue 实现的,这个队列最大长度为 Integer.MAX_VALUE,**容易导致 OOM**。**所以实际生产一般自己通过 ThreadPoolExecutor 的 7 个参数,自定义线程池**。
        3. 创建线程池推荐适用 ThreadPoolExecutor 及其 7 个参数手动创建:
        - corePoolSize 线程池的核心线程数
        - maximumPoolSize 能容纳的最大线程数
        - keepAliveTime 空闲线程存活时间
        - unit 存活的时间单位
        - workQueue 存放提交但未执行任务的队列
        - threadFactory 创建线程的工厂类
        - handler 等待队列满后的拒绝策略
        4. 为什么不允许适用不允许 Executors.的方式手动创建线程池,如下图:
        - ![image-20210728141417446](JUC/image-20210728141417446.png)



        #### 9、自定义线程池

        ![image-20210810234602627](JUC/image-20210810234602627.png)

        注意:以下的==任务队列==和==拒绝策略的接口==其实不用我们编写,可以使用JUC为我们提供的BlockingQueue,而拒绝策略的话,直接使用lambda表达式实现JUC提供好的拒绝策略接口中的reject方法即可。

        ##### 1、步骤1:自定义任务队列

        ```java
        class BlockingQueue<T> {
        // 1. 任务队列
        private Deque<T> queue = new ArrayDeque<>();

        // 2. 锁
        private ReentrantLock lock = new ReentrantLock();

        // 3. 生产者条件变量
        private Condition fullWaitSet = lock.newCondition();

        // 4. 消费者条件变量
        private Condition emptyWaitSet = lock.newCondition();

        // 5. 容量
        private int capcity;

        public BlockingQueue(int capcity) {
        this.capcity = capcity;
        }

        // 带超时阻塞获取
        public T poll(long timeout, TimeUnit unit) {
        lock.lock();
        try {
        // 将 timeout 统一转换为 纳秒 (时间统一管理)
        long nanos = unit.toNanos(timeout);
        while (queue.isEmpty()) {
        try {
        // 返回值是剩余时间
        if (nanos <= 0) {
        return null;
        }
        // 返回值是 等待时间-执行时间
        nanos = emptyWaitSet.awaitNanos(nanos);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        T t = queue.removeFirst();
        fullWaitSet.signal();
        return t;
        } finally {
        lock.unlock();
        }
        }

        // 阻塞获取
        public T take() {
        lock.lock();
        try {
        while (queue.isEmpty()) {
        try {
        emptyWaitSet.await();
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        T t = queue.removeFirst();
        fullWaitSet.signal();
        return t;
        } finally {
        lock.unlock();
        }
        }

        // 阻塞添加
        public void put(T task) {
        lock.lock();
        try {
        while (queue.size() == capcity) {
        try {
        log.debug("等待加入任务队列 {} ...", task);
        fullWaitSet.await();
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        } finally {
        lock.unlock();
        }
        }

        // 带超时时间阻塞添加
        public boolean offer(T task, long timeout, TimeUnit timeUnit) {
        lock.lock();
        try {
        long nanos = timeUnit.toNanos(timeout);
        while (queue.size() == capcity) {
        try {
        if(nanos <= 0) {
        return false;
        }
        log.debug("等待加入任务队列 {} ...", task);
        nanos = fullWaitSet.awaitNanos(nanos);
        } catch (InterruptedException e) {
        e.printStackTrace();
        }
        }
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        return true;
        } finally {
        lock.unlock();
        }
        }

        // 返回等待队列的长度
        public int size() {
        lock.lock();
        try {
        return queue.size();
        } finally {
        lock.unlock();
        }
        }

        // 尝试放入阻塞队列,若不能放进阻塞队列,执行拒绝策略
        public void tryPut(RejectPolicy<T> rejectPolicy, T task) {
        lock.lock();
        try {
        // 判断队列是否满
        if(queue.size() == capcity) {
        rejectPolicy.reject(this, task);
        } else { // 有空闲
        log.debug("加入任务队列 {}", task);
        queue.addLast(task);
        emptyWaitSet.signal();
        }
        } finally {
        lock.unlock();
        }
        }
        }
2、步骤2:自定义拒绝策略接口
1
2
3
4
5
// 拒绝策略 由于只有一个方法,可以使用函数式接口(lambda表达式)
@FunctionalInterface
interface RejectPolicy<T> {
void reject(BlockingQueue<T> queue, T task);
}
3、步骤3:自定义线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class ThreadPool {
// 任务队列
private BlockingQueue<Runnable> taskQueue;

// 线程集合
// 这里将线程进行进一步的封装,封装成Worker对象,有更多的操作空间
private HashSet<Worker> workers = new HashSet<>();

// 核心线程数
private int coreSize;

// 获取任务时的超时时间
private long timeout;

// 超时时间的单位
private TimeUnit timeUnit;

// 拒绝策略
private RejectPolicy<Runnable> rejectPolicy;

// 执行任务
public void execute(Runnable task) {
// 当任务数没有超过 coreSize 时,直接交给 worker 对象执行
// 如果任务数超过 coreSize 时,执行拒绝策略
synchronized (workers) {
if(workers.size() < coreSize) {
Worker worker = new Worker(task);
log.debug("新增 worker{}, {}", worker, task);
workers.add(worker);
worker.start();
} else {
// taskQueue.put(task);
// 将拒绝策略封装成一个接口交由调用者自己选择执行什么拒绝策略
// 1) 死等
// 2) 带超时等待
// 3) 让调用者放弃任务执行
// 4) 让调用者抛出异常
// 5) 让调用者自己执行任务
taskQueue.tryPut(rejectPolicy, task);
}
}
}

// 构造方法
public ThreadPool(int coreSize, long timeout, TimeUnit timeUnit, int queueCapcity, RejectPolicy<Runnable> rejectPolicy) {
this.coreSize = coreSize;
this.timeout = timeout;
this.timeUnit = timeUnit;
this.taskQueue = new BlockingQueue<>(queueCapcity);
this.rejectPolicy = rejectPolicy;
}

// Worker的实现
class Worker extends Thread{
// 任务
private Runnable task;

public Worker(Runnable task) {
this.task = task;
}

@Override
public void run() {
// 执行任务
// 1) 当 task 不为空,执行任务
// 2) 当 task 执行完毕,再接着从任务队列获取任务并执行
// while(task != null || (task = taskQueue.take()) != null) {
while(task != null || (task = taskQueue.poll(timeout, timeUnit)) != null) {
try {
log.debug("正在执行...{}", task);
task.run();
} catch (Exception e) {
e.printStackTrace();
} finally {
task = null;
}
}
synchronized (workers) {
log.debug("worker 被移除{}", this);
workers.remove(this);
}
}
}
}
4、步骤4:测试编写好的自定义线程池
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class TestPool {
public static void main(String[] args) {
ThreadPool threadPool = new ThreadPool(1,
1000, TimeUnit.MILLISECONDS, 1, (queue, task)->{
// 1. 死等
// queue.put(task);
// 2) 带超时等待
// queue.offer(task, 1500, TimeUnit.MILLISECONDS);
// 3) 让调用者放弃任务执行
// log.debug("放弃{}", task);
// 4) 让调用者抛出异常
// throw new RuntimeException("任务执行失败 " + task);
// 5) 让调用者自己执行任务
task.run();
});
for (int i = 0; i < 4; i++) {
int j = i;
threadPool.execute(() -> {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("{}", j);
});
}
}
}

17、ThreadPool线程池——ThreadPoolExecutor

1、BAT大厂的面试问题

  • 为什么要有线程池?
  • Java是实现和管理线程池有哪些方式?请简单举例如何使用。
  • 为什么很多公司不允许使用Executors去创建线程池?那么推荐怎么使用呢?
  • ThreadPoolExecutor有哪些核心的配置参数?请简要说明
  • ThreadPoolExecutor可以创建哪是哪三种线程池呢?
  • 当队列满了并且worker的数量达到maxSize的时候,会怎么样?
  • 说说ThreadPoolExecutor有哪些RejectedExecutionHandler策略?默认是什么策略?
  • 简要说下线程池的任务执行机制?
    • execute –> addWorker –>runworker (getTask)
  • 线程池中任务是如何提交的?
  • 线程池中任务是如何关闭的?
  • 在配置线程池的时候需要考虑哪些配置因素?
  • 如何监控线程池的状态?

2、为什么需要线程池

线程池能够对线程进行统一分配,调优和监控:

  • 降低资源消耗(线程无限制地创建,然后使用完毕后销毁)
  • 提高响应速度(无须创建线程)
  • 提高线程的可管理性

3、ThreadPoolExecutor例子

Java是如何实现和管理线程池的?

从JDK 5开始,把工作单元与执行机制分离开来,工作单元包括Runnable和Callable,而执行机制由Executor框架提供

WorkerThread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class WorkerThread implements Runnable {

private String command;

public WorkerThread(String s){
this.command=s;
}

@Override
public void run() {
System.out.println(Thread.currentThread().getName()+" Start. Command = "+command);
processCommand();
System.out.println(Thread.currentThread().getName()+" End.");
}

private void processCommand() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

@Override
public String toString(){
return this.command;
}
}

SimpleThreadPool:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleThreadPool {

public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
Runnable worker = new WorkerThread("" + i);
executor.execute(worker);
}
executor.shutdown(); // This will make the executor accept no new threads and finish all existing threads in the queue
while (!executor.isTerminated()) { // Wait until all threads are finish,and also you can use "executor.awaitTermination();" to wait
}
System.out.println("Finished all threads");
}

}

程序中我们创建了固定大小为五个工作线程的线程池。然后分配给线程池十个工作,因为线程池大小为五,它将启动五个工作线程先处理五个工作,其他的工作则处于等待状态,一旦有工作完成,空闲下来工作线程就会捡取等待队列里的其他工作进行执行。

这里是以上程序的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pool-1-thread-2 Start. Command = 1
pool-1-thread-4 Start. Command = 3
pool-1-thread-1 Start. Command = 0
pool-1-thread-3 Start. Command = 2
pool-1-thread-5 Start. Command = 4
pool-1-thread-4 End.
pool-1-thread-5 End.
pool-1-thread-1 End.
pool-1-thread-3 End.
pool-1-thread-3 Start. Command = 8
pool-1-thread-2 End.
pool-1-thread-2 Start. Command = 9
pool-1-thread-1 Start. Command = 7
pool-1-thread-5 Start. Command = 6
pool-1-thread-4 Start. Command = 5
pool-1-thread-2 End.
pool-1-thread-4 End.
pool-1-thread-3 End.
pool-1-thread-5 End.
pool-1-thread-1 End.
Finished all threads

输出表明线程池中至始至终只有五个名为 “pool-1-thread-1” 到 “pool-1-thread-5” 的五个线程,这五个线程不随着工作的完成而消亡,会一直存在,并负责执行分配给线程池的任务,直到线程池消亡。

Executors 类提供了使用了 ThreadPoolExecutor 的简单的 ExecutorService 实现,但是 ThreadPoolExecutor 提供的功能远不止于此。我们可以在创建 ThreadPoolExecutor 实例时指定活动线程的数量,我们也可以限制线程池的大小并且创建我们自己的 RejectedExecutionHandler 实现来处理不能适应工作队列的工作。

这里是我们自定义的 RejectedExecutionHandler 接口的实现:

1
2
3
4
5
6
7
8
9
10
11
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

public class RejectedExecutionHandlerImpl implements RejectedExecutionHandler {

@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.out.println(r.toString() + " is rejected");
}

}

ThreadPoolExecutor 提供了一些方法,我们可以使用这些方法来查询 executor 的当前状态,线程池大小,活动线程数量以及任务数量。因此我是用来一个监控线程在特定的时间间隔内打印 executor 信息。

MyMonitorThread.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.util.concurrent.ThreadPoolExecutor;

public class MyMonitorThread implements Runnable {

private ThreadPoolExecutor executor;

private int seconds;

private boolean run=true;

public MyMonitorThread(ThreadPoolExecutor executor, int delay) {
this.executor = executor;
this.seconds=delay;
}

public void shutdown(){
this.run=false;
}

@Override
public void run() {
while(run){
System.out.println(
String.format("[monitor] [%d/%d] Active: %d, Completed: %d, Task: %d, isShutdown: %s, isTerminated: %s",
this.executor.getPoolSize(),
this.executor.getCorePoolSize(),
this.executor.getActiveCount(),
this.executor.getCompletedTaskCount(),
this.executor.getTaskCount(),
this.executor.isShutdown(),
this.executor.isTerminated()));
try {
Thread.sleep(seconds*1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}
}

这里是使用 ThreadPoolExecutor 的线程池实现例子。

WorkerPool.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class WorkerPool {

public static void main(String args[]) throws InterruptedException{
//RejectedExecutionHandler implementation
RejectedExecutionHandlerImpl rejectionHandler = new RejectedExecutionHandlerImpl();
//Get the ThreadFactory implementation to use
ThreadFactory threadFactory = Executors.defaultThreadFactory();
//creating the ThreadPoolExecutor
ThreadPoolExecutor executorPool = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(2), threadFactory, rejectionHandler);
//start the monitoring thread
MyMonitorThread monitor = new MyMonitorThread(executorPool, 3);
Thread monitorThread = new Thread(monitor);
monitorThread.start();
//submit work to the thread pool
for(int i=0; i<10; i++){
executorPool.execute(new WorkerThread("cmd"+i));
}

Thread.sleep(30000);
//shut down the pool
executorPool.shutdown();
//shut down the monitor thread
Thread.sleep(5000);
monitor.shutdown();

}
}

注意在初始化 ThreadPoolExecutor 时,我们保持初始池大小为 2,最大池大小为 4 而工作队列大小为 2。因此如果已经有四个正在执行的任务而此时分配来更多任务的话,工作队列将仅仅保留他们(新任务)中的两个,其他的将会被 RejectedExecutionHandlerImpl 处理。

上面程序的输出可以证实以上观点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
pool-1-thread-1 Start. Command = cmd0
pool-1-thread-4 Start. Command = cmd5
cmd6 is rejected
pool-1-thread-3 Start. Command = cmd4
pool-1-thread-2 Start. Command = cmd1
cmd7 is rejected
cmd8 is rejected
cmd9 is rejected
[monitor] [0/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false
[monitor] [4/2] Active: 4, Completed: 0, Task: 6, isShutdown: false, isTerminated: false
pool-1-thread-4 End.
pool-1-thread-1 End.
pool-1-thread-2 End.
pool-1-thread-3 End.
pool-1-thread-1 Start. Command = cmd3
pool-1-thread-4 Start. Command = cmd2
[monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false
[monitor] [4/2] Active: 2, Completed: 4, Task: 6, isShutdown: false, isTerminated: false
pool-1-thread-1 End.
pool-1-thread-4 End.
[monitor] [4/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [2/2] Active: 0, Completed: 6, Task: 6, isShutdown: false, isTerminated: false
[monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true
[monitor] [0/2] Active: 0, Completed: 6, Task: 6, isShutdown: true, isTerminated: true

注意 executor 的活动任务、完成任务以及所有完成任务,这些数量上的变化。我们可以调用 shutdown() 方法来结束所有提交的任务并终止线程池。

4、ThreadPoolExecutor使用详解

其实java线程池的实现原理很简单,说白了就是一个线程集合workerSet和一个阻塞队列workQueue。当用户向线程池提交一个任务(也就是线程)时,线程池会先将任务放入workQueue中。workerSet中的线程会不断的从workQueue中获取线程然后执行。当workQueue中没有任务的时候,worker就会阻塞,直到队列中有任务了就取出来继续执行。

java-thread-x-executors-1

1、Execute原理

当一个任务提交至线程池之后:

  1. 线程池首先当前运行的线程数量是否少于corePoolSize。如果是,则创建一个新的工作线程来执行任务。如果都在执行任务,则进入2.
  2. 判断BlockingQueue是否已经满了,倘若还没有满,则将线程放入BlockingQueue。否则进入3.
  3. 如果创建一个新的工作线程将使当前运行的线程数量超过maximumPoolSize,则交给RejectedExecutionHandler来处理任务。

当ThreadPoolExecutor创建新线程时,通过CAS来更新线程池的状态ctl。

2、参数
1
2
3
4
5
6
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
  • corePoolSize 线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize,即使有其他空闲线程能够执行新来的任务,也会继续创建线程;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。
  • workQueue 用来保存等待被执行的任务的阻塞队列。在JDK中提供了如下阻塞队列:
    • ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务;
    • LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene;
    • SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene;
    • PriorityBlockingQuene:具有优先级的无界阻塞队列;

LinkedBlockingQueueArrayBlockingQueue在插入删除节点性能方面更优,但是二者在put(), take()任务的时均需要加锁,SynchronousQueue使用无锁算法,根据节点的状态判断执行,而不需要用到锁,其核心是Transfer.transfer()

  • maximumPoolSize 线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;当阻塞队列是无界队列,则maximumPoolSize则不起作用,因为无法提交至核心线程池的线程会一直持续地放入workQueue。
  • keepAliveTime 线程空闲时的存活时间,即当线程没有任务执行时,该线程继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用,超过这个时间的空闲线程将被终止; ——针对救急线程
  • unit keepAliveTime的单位 —— 针对救急线程
  • threadFactory 创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。默认为DefaultThreadFactory
  • handler 线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略:
    • AbortPolicy:直接抛出异常,默认策略;
    • CallerRunsPolicy:用调用者所在的线程来执行任务;
    • DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
    • DiscardPolicy:直接丢弃任务;

当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义拒绝策略,如记录日志或持久化存储不能处理的任务

image-20210811040044445

根据这个构造方法,JDK Executors 类中提供了众多工厂方法来创建各种用途的线程池

3、三种类型
1、newFixedThreadPool
1
2
3
4
5
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}

特点:

  • 线程池的线程数量达corePoolSize后,即使线程池没有可执行任务时,也不会释放线程。
  • 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间
  • 阻塞队列是无界的,可以放任意数量的任务

FixedThreadPool的工作队列为无界队列LinkedBlockingQueue(队列容量为Integer.MAX_VALUE),这会导致以下问题:

  • 线程池里的线程数量不超过corePoolSize,这导致了maximumPoolSize和keepAliveTime将会是个无用参数
  • 由于使用了无界队列,所以FixedThreadPool永远不会拒绝,即饱和策略失效

评价:适用于任务量已知,相对耗时的任务

2、newSingleThreadPool
1
2
3
4
5
6
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}

特点:

  • 初始化的线程池中只有一个线程,如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行。

由于使用了无界队列,所以SingleThreadPool永远不会拒绝,即饱和策略失效。

使用场景:

  • 希望多个任务排队执行。
  • 线程数固定为 1,任务数多于 1 时,会放入无界队列排队。
  • 任务执行完毕,这唯一的线程也不会被释放。
3、newCachedThreadPool
1
2
3
4
5
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}

特点:

  • 核心线程数是 0, 最大线程数是 Integer.MAX_VALUE,救急线程的空闲生存时间是 60s,意味着
    • 全部都是救急线程(60s 后可以回收)
    • 救急线程可以无限创建
  • 队列采用了 SynchronousQueue 实现特点是,它没有容量,没有线程来取是放不进去的(一手交钱、一手交货)

线程池的线程数可达到Integer.MAX_VALUE,即2147483647,内部使用SynchronousQueue作为阻塞队列; 和newFixedThreadPool创建的线程池不同,newCachedThreadPool在没有任务执行时,当线程的空闲时间超过keepAliveTime,会自动释放线程资源,当提交新任务时,如果没有空闲线程,则创建新线程执行任务,会导致一定的系统开销; 执行过程与前两种稍微不同:

  1. 主线程调用SynchronousQueue的offer()方法放入task,倘若此时线程池中有空闲的线程尝试读取 SynchronousQueue的task,即调用了SynchronousQueue的poll(),那么主线程将该task交给空闲线程。否则执行(2)
  2. 当线程池为空或者没有空闲的线程,则创建新的线程执行任务。
  3. 执行完任务的线程倘若在60s内仍空闲,则会被终止。因此长时间空闲的CachedThreadPool不会持有任何线程资源。

评价:整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线程。 适合任务数比较密集,但每个任务执行时间较短的情况

4、区别
  • 自己创建一个单线程串行执行任务,如果任务执行失败而终止那么没有任何补救措施,而线程池还会新建一个线程,保证池的正常工作
  • Executors.newSingleThreadExecutor() 线程个数始终为1,不能修改
    • FinalizableDelegatedExecutorService 应用的是==装饰器模式==,只对外暴露了 ExecutorService 接口,因此不能调用 ThreadPoolExecutor 中特有的方法
  • Executors.newFixedThreadPool(1) 初始时为1,以后还可以修改
    • 对外暴露的是 ThreadPoolExecutor 对象,可以强转后调用 setCorePoolSize 等方法进行修改
4、关闭线程池

遍历线程池中的所有线程,然后逐个调用线程的interrupt方法来中断线程。

1、关闭方式——shutdown

将线程池里的线程状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程。

2、关闭方式——shutdownNow

将线程池里的线程状态设置成STOP状态,然后停止所有正在执行或暂停任务的线程。只要调用这两个关闭方法中的任意一个,isShutDown() 返回true。当所有任务都成功关闭了,isTerminated()返回true。

5、ThreadPoolExecutor源码详解

1、几个关键属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//这个属性是用来存放 当前运行的worker数量以及线程池状态的
//int是32位的,这里把int的高3位拿来充当线程池状态的标志位,后29位拿来充当当前运行worker的数量
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
//存放任务的阻塞队列
private final BlockingQueue<Runnable> workQueue;
//worker的集合,用set来存放
private final HashSet<Worker> workers = new HashSet<Worker>();
//历史达到的worker数最大值
private int largestPoolSize;
//当队列满了并且worker的数量达到maxSize的时候,执行具体的拒绝策略
private volatile RejectedExecutionHandler handler;
//超出coreSize的worker的生存时间
private volatile long keepAliveTime;
//常驻worker的数量
private volatile int corePoolSize;
//最大worker的数量,一般当workQueue满了才会用到这个参数
private volatile int maximumPoolSize;
2、内部状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;

// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

其中AtomicInteger变量ctl的功能非常强大:利用低29位表示线程池中线程数,通过高3位表示线程池的运行状态:

  • RUNNING:-1 << COUNT_BITS,即高3位为111该状态的线程池会接收新任务,并处理阻塞队列中的任务
  • SHUTDOWN:0 << COUNT_BITS,即高3位为000该状态的线程池不会接收新任务,但会处理阻塞队列中的任务
  • STOP:1 << COUNT_BITS,即高3位为001该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务
  • TIDYING:2 << COUNT_BITS,即高3位为010所有任务全执行完毕,活动线程为 0 即将进入终结
  • TERMINATED:3 << COUNT_BITS,即高3位为011terminated()方法已经执行完成

img

状态名 高3位 接收新任务 处理阻塞队列任务 说明
RUNNING 111 Y Y
SHUTDOWN 000 N Y 不会接收新任务,但会处理阻塞队列剩余任务
STOP 001 N N 会中断正在执行的任务,并抛弃阻塞队列任务
TIDYING 010 - - 任务全执行完毕,活动线程为 0 即将进入终结
TERMINATED 011 - - 终结状态

从数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING

注意:为什么RUNNING为111确是最小的?

因为计算机都是补码来记录,所以111其实是-1

这些信息存储在一个原子变量 ctl 中,目的是将线程池状态与线程个数合二为一,这样就可以用一次 cas 原子操作进行赋值

1
2
3
4
// c 为旧值, ctlOf 返回结果为新值
ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c))));
// rs 为高 3 位代表线程池状态, wc 为低 29 位代表线程个数,ctl 是合并它们
private static int ctlOf(int rs, int wc) { return rs | wc; }
3、任务的执行

execute –> addWorker –>runworker (getTask)

线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。 从Woker类的构造方法实现可以发现:线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。 firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

1、execute()方法

ThreadPoolExecutor.execute(task)实现了Executor.execute(task)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
//workerCountOf获取线程池的当前线程数;小于corePoolSize,执行addWorker创建新线程执行command任务
if (addWorker(command, true))
return;
c = ctl.get();
}
// double check: c, recheck
// 线程池处于RUNNING状态,把提交的任务成功放入阻塞队列中
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// recheck and if necessary 回滚到入队操作前,即倘若线程池shutdown状态,就remove(command)
//如果线程池没有RUNNING,成功从阻塞队列中删除任务,执行reject方法处理任务
if (! isRunning(recheck) && remove(command))
reject(command);
//线程池处于running状态,但是没有线程,则创建线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 往线程池中创建新的线程失败,则reject任务
else if (!addWorker(command, false))
reject(command);
}

为什么需要double check线程池的状态?

在多线程环境下,线程池的状态时刻在变化,而ctl.get()是非原子操作,很有可能刚获取了线程池状态后线程池状态就改变了。判断是否将command加入workque是线程池之前的状态。倘若没有double check,万一线程池处于非running状态(在多线程环境下很有可能发生),那么command永远不会执行。

2、addWorker方法

从方法execute的实现可以看出:addWorker主要负责创建新的线程并执行任务线程池创建新线程执行任务时,需要 获取全局锁:

1
private final ReentrantLock mainLock = new ReentrantLock();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
private boolean addWorker(Runnable firstTask, boolean core) {
// CAS更新线程池数量
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;

for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}

boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 线程池重入锁
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());

if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start(); // 线程启动,执行任务(Worker.thread(firstTask).start());
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
3、Worker类的runworker方法
1
2
3
4
5
6
7
8
9
10
11
12
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this); // 创建线程
}
/** Delegates main run loop to outer runWorker */
public void run() {
runWorker(this);
}
// ...
}
  • 继承了AQS类,可以方便的实现工作线程的中止操作;
  • 实现了Runnable接口,可以将自身作为一个任务在工作线程中执行;
  • 当前提交的任务firstTask作为参数传入Worker的构造方法;

一些属性还有构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
//运行的线程,前面addWorker方法中就是直接通过启动这个线程来启动这个worker
final Thread thread;
//当一个worker刚创建的时候,就先尝试执行这个任务
Runnable firstTask;
//记录完成任务的数量
volatile long completedTasks;

Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
//创建一个Thread,将自己设置给他,后面这个thread启动的时候,也就是执行worker的run方法
this.thread = getThreadFactory().newThread(this);
}

runWorker方法是线程池的核心:

  • 线程启动之后,通过unlock方法释放锁,设置AQS的state为0,表示运行可中断;
  • Worker执行firstTask或从workQueue中获取任务:
    • 进行加锁操作,保证thread不被其他线程中断(除非线程池被中断)
    • 检查线程池状态,倘若线程池处于中断状态,当前线程将中断。
    • 执行beforeExecute
    • 执行任务的run方法
    • 执行afterExecute方法
    • 解锁操作

通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
// 先执行firstTask,再从workerQueue中取task(getTask())

while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
4、getTask方法

下面来看一下getTask()方法,这里面涉及到keepAliveTime的使用,从这个方法我们可以看出线程池是怎么让超过corePoolSize的那部分worker销毁的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?

for (;;) {
int c = ctl.get();
int rs = runStateOf(c);

// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}

int wc = workerCountOf(c);

// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}

try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}

注意这里一段代码是keepAliveTime起作用的关键:

1
2
3
4
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();

allowCoreThreadTimeOut为false,线程即使空闲也不会被销毁;倘若为ture,在keepAliveTime内仍空闲则会被销毁。

如果线程允许空闲等待而不被销毁timed == false,workQueue.take任务:如果阻塞队列为空,当前线程会被挂起等待;当队列中有任务加入时,线程被唤醒,take方法返回任务,并执行;

如果线程不允许无休止空闲timed == true,workQueue.poll任务:如果在keepAliveTime时间内,阻塞队列还是没有任务,则返回null;

4、任务的提交
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 执行任务
void execute(Runnable command);

// 提交任务 task,用返回值 Future 获得任务执行结果
<T> Future<T> submit(Callable<T> task);

// 提交 tasks 中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;

// 提交 tasks 中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit) throws InterruptedException;

// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;

// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;

img

  1. submit任务,等待线程池execute
  2. 执行FutureTask类的get方法时,会把主线程封装成WaitNode节点并保存在waiters链表中, 并阻塞等待运行结果;
  3. FutureTask任务执行完成后,通过UNSAFE设置waiters相应的waitNode为null,并通过LockSupport类unpark方法唤醒主线程;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Test{
public static void main(String[] args) {

ExecutorService es = Executors.newCachedThreadPool();
Future<String> future = es.submit(new Callable<String>() {
@Override
public String call() throws Exception {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "future result";
}
});
try {
String result = future.get();
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
}
}

在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。

  1. Callable接口类似于Runnable,只是Runnable没有返回值。
  2. Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果;
  3. Future.get方法会导致主线程阻塞,直到Callable任务执行完成;
1、submit方法

AbstractExecutorService.submit()实现了ExecutorService.submit() 可以获取执行完的返回值,而ThreadPoolExecutor 是AbstractExecutorService.submit()的子类,所以submit方法也是ThreadPoolExecutor的方法。

1
2
3
4
5
6
// submit()在ExecutorService中的定义
<T> Future<T> submit(Callable<T> task);

<T> Future<T> submit(Runnable task, T result);

Future<?> submit(Runnable task);
1
2
3
4
5
6
7
8
// submit方法在AbstractExecutorService中的实现
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
// 通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}

通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。通过Executor.execute方法提交FutureTask到线程池中等待被执行,最终执行的是FutureTask的run方法;

2、FutureTask对象

public class FutureTask<V> implements RunnableFuture<V> 可以将FutureTask提交至线程池中等待被执行(通过FutureTask的run方法来执行)

  • 内部状态

    • /* The run state of this task, initially NEW. 
          * ...
          * Possible state transitions:
          * NEW -> COMPLETING -> NORMAL
          * NEW -> COMPLETING -> EXCEPTIONAL
          * NEW -> CANCELLED
          * NEW -> INTERRUPTING -> INTERRUPTED
          */
      private volatile int state;
      private static final int NEW          = 0;
      private static final int COMPLETING   = 1;
      private static final int NORMAL       = 2;
      private static final int EXCEPTIONAL  = 3;
      private static final int CANCELLED    = 4;
      private static final int INTERRUPTING = 5;
      private static final int INTERRUPTED  = 6;
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13

      - 内部状态的修改通过sun.misc.Unsafe修改

      - get方法

      ```java

      public V get() throws InterruptedException, ExecutionException {
      int s = state;
      if (s <= COMPLETING)
      s = awaitDone(false, 0L);
      return report(s);
      }
    • 内部通过awaitDone方法对主线程进行阻塞,具体实现如下:

    • private int awaitDone(boolean timed, long nanos)
          throws InterruptedException {
          final long deadline = timed ? System.nanoTime() + nanos : 0L;
          WaitNode q = null;
          boolean queued = false;
          for (;;) {
              if (Thread.interrupted()) {
                  removeWaiter(q);
                  throw new InterruptedException();
              }
      
              int s = state;
              if (s > COMPLETING) {
                  if (q != null)
                      q.thread = null;
                  return s;
              }
              else if (s == COMPLETING) // cannot time out yet
                  Thread.yield();
              else if (q == null)
                  q = new WaitNode();
              else if (!queued)
                  queued = UNSAFE.compareAndSwapObject(this, waitersOffset,q.next = waiters, q);
              else if (timed) {
                  nanos = deadline - System.nanoTime();
                  if (nanos <= 0L) {
                      removeWaiter(q);
                      return state;
                  }
                  LockSupport.parkNanos(this, nanos);
              }
              else
                  LockSupport.park(this);
          }
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44

      - 如果主线程被中断,则抛出中断异常;

      - 判断FutureTask当前的state,如果大于COMPLETING,说明任务已经执行完成,则直接返回;

      - 如果当前state等于COMPLETING,说明任务已经执行完,这时主线程只需通过yield方法让出cpu资源,等待state变成NORMAL;

      - 通过WaitNode类封装当前线程,并通过UNSAFE添加到waiters链表;

      - 最终通过LockSupport的park或parkNanos挂起线程;

      - run方法

      - ```java
      public void run() {
      if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread()))
      return;
      try {
      Callable<V> c = callable;
      if (c != null && state == NEW) {
      V result;
      boolean ran;
      try {
      result = c.call();
      ran = true;
      } catch (Throwable ex) {
      result = null;
      ran = false;
      setException(ex);
      }
      if (ran)
      set(result);
      }
      } finally {
      // runner must be non-null until state is settled to
      // prevent concurrent calls to run()
      runner = null;
      // state must be re-read after nulling runner to prevent
      // leaked interrupts
      int s = state;
      if (s >= INTERRUPTING)
      handlePossibleCancellationInterrupt(s);
      }
      }
    • FutureTask.run方法是在线程池中被执行的,而非主线程:

      1. 通过执行Callable任务的call方法;
      2. 如果call执行成功,则通过set方法保存结果;
      3. 如果call执行有异常,则通过setException保存异常;
5、任务的关闭

shutdown方法会将线程池的状态设置为SHUTDOWN,线程池进入这个状态后,就拒绝再接受任务,然后会将剩余的任务全部执行完:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
线程池状态变为 SHUTDOWN
- 不会接收新任务
- 但已提交任务会执行完
- 此方法不会阻塞调用线程的执行
*/
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//检查是否可以关闭线程
checkShutdownAccess();
//设置线程池状态
advanceRunState(SHUTDOWN);
//尝试中断worker,仅会打断空闲线程
interruptIdleWorkers();
//预留方法,留给子类实现
onShutdown(); // hook for ScheduledThreadPoolExecutor——扩展点
} finally {
mainLock.unlock();
}
// 尝试终结(没有运行的线程可以立刻终结,如果还有运行的线程也不会等)
tryTerminate();
}

private void interruptIdleWorkers() {
interruptIdleWorkers(false);
}

private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有的worker
for (Worker w : workers) {
Thread t = w.thread;
//先尝试调用w.tryLock(),如果获取到锁,就说明worker是空闲的,就可以直接中断它
//注意的是,worker自己本身实现了AQS同步框架,然后实现的类似锁的功能
//它实现的锁是不可重入的,所以如果worker在执行任务的时候,会先进行加锁,这里tryLock()就会返回false
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}

shutdownNow做的比较绝,它先将线程池状态设置为STOP,然后拒绝所有提交的任务。最后中断左右正在运行中的worker,然后清空任务队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
线程池状态变为 STOP
- 不会接收新任务
- 会将队列中的任务返回
- 并用 interrupt 的方式中断正在执行的任务
*/
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//检测权限,修改线程池状态
advanceRunState(STOP);
//中断所有的worker,打断所有线程
interruptWorkers();
//获取队列中剩余任务,清空任务队列
tasks = drainQueue();
} finally {
mainLock.unlock();
}
// 尝试终结(没有运行的线程可以立刻终结,如果还有运行的线程也不会等)
tryTerminate();
return tasks;
}

private void interruptWorkers() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有worker,然后调用中断方法
for (Worker w : workers)
w.interruptIfStarted();
} finally {
mainLock.unlock();
}
}
6、其他方法
1
2
3
4
5
6
7
8
// 不在 RUNNING 状态的线程池,此方法就返回 true
boolean isShutdown();

// 线程池状态是否是 TERMINATED
boolean isTerminated();

// 调用 shutdown 后,由于调用线程并不会等待所有任务运行结束,因此如果它想在线程池 TERMINATED 后做些事情,可以利用此方法等待
boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;

6、异常的处理

使用线程池创建线程时,如果线程内部发生异常的话,是不会抛出或者在控制台打印异常信息的,所以需要我们对可能出现异常进行异常处理,对于异常的处理有以下几种方法:

  • 线程自己捕捉:线程在代码里对可能出现的异常进行try catch捕捉

    • ExecutorService pool = Executors.newFixedThreadPool(1);
      pool.submit(() -> {
          try {
              log.debug("task1");
              int i = 1 / 0;
          } catch (Exception e) {
              log.error("error:", e);
          }
      });
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11

      - 通过Future进行结果的返回来判断是否发生异常:

      - ```java
      ExecutorService pool = Executors.newFixedThreadPool(1);
      Future<Boolean> f = pool.submit(() -> {
      log.debug("task1");
      inti = 1/0;
      return true ;
      });
      Log. debug("result:{}", f.get();

7、更深入理解

1、为什么线程池不允许使用Executors去创建?

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:  主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:  主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。
1、推荐方式1

首先引入:commons-lang3包

1
2
ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1,
new BasicThreadFactory.Builder().namingPattern("example-schedule-pool-%d").daemon(true).build());
2、推荐方式2

首先引入:com.google.guava包

1
2
3
4
5
6
7
8
9
10
ThreadFactory namedThreadFactory = new ThreadFactoryBuilder().setNameFormat("demo-pool-%d").build();

//Common Thread Pool
ExecutorService pool = new ThreadPoolExecutor(5, 200, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), namedThreadFactory, new ThreadPoolExecutor.AbortPolicy());

// excute
pool.execute(()-> System.out.println(Thread.currentThread().getName()));

//gracefully shutdown
pool.shutdown();
3、推荐方式3

spring配置线程池方式:自定义线程工厂bean需要实现ThreadFactory,可参考该接口的其它默认实现类,使用方式直接注入bean调用execute(Runnable task)方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
<bean id="userThreadPool" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
<property name="corePoolSize" value="10" />
<property name="maxPoolSize" value="100" />
<property name="queueCapacity" value="2000" />

<property name="threadFactory" value= threadFactory />
<property name="rejectedExecutionHandler">
<ref local="rejectedExecutionHandler" />
</property>
</bean>

//in code
userThreadPool.execute(thread);
2、配置线程池需要考虑的因素

从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。

性质不同的任务可用使用不同规模的线程池分开处理:

  • CPU密集型:尽可能少的线程,Ncpu+1
  • IO密集型:尽可能多的线程,Ncpu*2,比如数据库连接池
  • 混合型:CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。

具体也可以参考8、并发的多线程设计模式的7、工作线程模式查看

3、监控线程池的状态

可以使用ThreadPoolExecutor以下方法:

  • getTaskCount() Returns the approximate total number of tasks that have ever been scheduled for execution.
    • 返回计划执行的任务的大致总数。
  • getCompletedTaskCount() Returns the approximate total number of tasks that have completed execution.
    • 返回已完成执行的任务的大致总数
    • 返回结果少于getTaskCount()。
  • getLargestPoolSize() Returns the largest number of threads that have ever simultaneously been in the pool.
    • 返回池中同时存在的最大线程数。
    • 返回结果小于等于maximumPoolSize
  • getPoolSize() Returns the current number of threads in the pool.
    • 返回池中当前的线程数。
  • getActiveCount() Returns the approximate number of threads that are actively executing tasks.
    • 返回当前正在执行任务的线程的大致数目。

18、ThreadPool线程池——ScheduledThreadPoolExecutor

在『任务调度线程池』功能加入之前,可以使用 java.util.Timer 来实现定时功能,Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务

如果使用的是ScheduledThreadPoolExecutor前一个任务的延迟或异常都不会影响到之后的任务,但是异常信息不会被打印出来。需要我们对异常信息进行处理

在很多业务场景中,我们可能需要周期性的运行某项任务来获取结果,比如周期数据统计,定时发送数据等。在并发包出现之前,Java 早在1.3就提供了 Timer 类(只需要了解,目前已渐渐被 ScheduledThreadPoolExecutor 代替)来适应这些业务场景。随着业务量的不断增大,我们可能需要多个工作线程运行任务来尽可能的增加产品性能,或者是需要更高的灵活性来控制和监控这些周期业务。这些都是 ScheduledThreadPoolExecutor 诞生的必然性。

1、BAT大厂的面试问题

  • ScheduledThreadPoolExecutor要解决什么样的问题?
  • ScheduledThreadPoolExecutor相比ThreadPoolExecutor有哪些特性?
  • ScheduledThreadPoolExecutor有什么样的数据结构,核心内部类和抽象类?
  • ScheduledThreadPoolExecutor有哪两个关闭策略?区别是什么?
  • ScheduledThreadPoolExecutor中scheduleAtFixedRate 和 scheduleWithFixedDelay区别是什么?
  • 为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
  • Executors 提供了几种方法来构造 ScheduledThreadPoolExecutor?

2、ScheduledThreadPoolExecutor简介

ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。和 ThreadPoolExecutor 相比,它还具有以下几种特性:

  • 使用专门的任务类型——ScheduledFutureTask 来执行周期任务,也可以接收不需要时间调度的任务(这些任务通过 ExecutorService 来执行)。
  • 使用专门的存储队列——DelayedWorkQueue 来存储任务,DelayedWorkQueue 是无界延迟队列DelayQueue 的一种。相比ThreadPoolExecutor也简化了执行机制(delayedExecute方法,后面单独分析)。
  • 支持可选的run-after-shutdown参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。

3、ScheduledThreadPoolExecutor数据结构

img

ScheduledThreadPoolExecutor继承自 ThreadPoolExecutor,ScheduledThreadPoolExecutor 内部构造了两个内部类 ScheduledFutureTaskDelayedWorkQueue:

  • ScheduledFutureTask:继承了FutureTask,说明是一个异步运算任务;最上层分别实现了Runnable、Future、Delayed接口,说明它是一个可以延迟执行的异步运算任务
  • DelayedWorkQueue:这是 ScheduledThreadPoolExecutor 为存储周期或延迟任务专门定义的一个延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。它内部只允许存储 RunnableScheduledFuture 类型的任务。与 DelayQueue 的不同之处就是它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 是利用了 PriorityQueue 的二叉堆结构)。

4、ScheduledThreadPoolExecutor源码解析

1、内部类ScheduledFutureTask
1、属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//为相同延时任务提供的顺序编号
private final long sequenceNumber;

//任务可以执行的时间,纳秒级
private long time;

//重复任务的执行周期时间,纳秒级。
private final long period;

//重新入队的任务
RunnableScheduledFuture<V> outerTask = this;

//延迟队列的索引,以支持更快的取消操作
int heapIndex;
  • sequenceNumber:当两个任务有相同的延迟时间时,按照 FIFO 的顺序入队。sequenceNumber 就是为相同延时任务提供的顺序编号
  • time任务可以执行时的时间,==纳秒级==,通过triggerTime方法计算得出。
  • period:任务的执行周期时间,==纳秒级==。
    • 正数表示固定速率执行(为scheduleAtFixedRate提供服务),
    • 负数表示固定延迟执行(为scheduleWithFixedDelay提供服务),
    • 0表示不重复任务。
  • outerTask重新入队的任务,通过reExecutePeriodic方法入队重新排序。
2、核心方法run()
1
2
3
4
5
6
7
8
9
10
11
12
public void run() {
boolean periodic = isPeriodic();//是否为周期任务
if (!canRunInCurrentRunState(periodic))//当前状态是否可以执行
cancel(false);
else if (!periodic)
//不是周期任务,直接执行
ScheduledFutureTask.super.run();
else if (ScheduledFutureTask.super.runAndReset()) {
setNextRunTime();//设置下一次运行时间
reExecutePeriodic(outerTask);//重排序一个周期任务
}
}

说明:ScheduledFutureTask 的run方法重写了 FutureTask 的版本,以便执行周期任务时重置/重排序任务。任务的执行通过父类 FutureTask 的run实现。

内部有两个针对周期任务的方法:

  • setNextRunTime()用来设置下一次运行的时间,源码如下:

    • //设置下一次执行任务的时间
      private void setNextRunTime() {
          long p = period;
          if (p > 0)  //固定速率执行,scheduleAtFixedRate
              time += p;
          else
              time = triggerTime(-p);  //固定延迟执行,scheduleWithFixedDelay
      }
      //计算固定延迟任务的执行时间
      long triggerTime(long delay) {
          return now() +
              ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay));
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15

      - `reExecutePeriodic()`:**周期任务重新入队等待下一次执行**,源码如下:

      - ```java
      //重排序一个周期任务
      void reExecutePeriodic(RunnableScheduledFuture<?> task) {
      if (canRunInCurrentRunState(true)) {//池关闭后可继续执行
      super.getQueue().add(task);//任务入列
      //重新检查run-after-shutdown参数,如果不能继续运行就移除队列任务,并取消任务的执行
      if (!canRunInCurrentRunState(true) && remove(task))
      task.cancel(false);
      else
      ensurePrestart();//启动一个新的线程等待任务
      }
      }

reExecutePeriodic与delayedExecute的执行策略一致,只不过reExecutePeriodic不会执行拒绝策略而是直接丢掉任务

3、cancel方法
1
2
3
4
5
6
public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
if (cancelled && removeOnCancel && heapIndex >= 0)
remove(this);
return cancelled;
}

ScheduledFutureTask.cancel本质上由其父类 FutureTask.cancel 实现取消任务成功后会根据removeOnCancel参数决定是否从队列中移除此任务。

2、核心属性
1
2
3
4
5
6
7
8
9
10
11
//关闭后继续执行已经存在的周期任务 
private volatile boolean continueExistingPeriodicTasksAfterShutdown;

//关闭后继续执行已经存在的延时任务
private volatile boolean executeExistingDelayedTasksAfterShutdown = true;

//取消任务后移除
private volatile boolean removeOnCancel = false;

//为相同延时的任务提供的顺序编号,保证任务之间的FIFO顺序
private static final AtomicLong sequencer = new AtomicLong();
  • continueExistingPeriodicTasksAfterShutdownexecuteExistingDelayedTasksAfterShutdownScheduledThreadPoolExecutor 定义的 run-after-shutdown 参数,用来控制池关闭之后的任务执行逻辑。
  • removeOnCancel用来控制任务取消后是否从队列中移除。当一个已经提交的周期或延迟任务在运行之前被取消,那么它之后将不会运行。默认配置下,这种已经取消的任务在届期之前不会被移除。 通过这种机制,可以方便检查和监控线程池状态,但也可能导致已经取消的任务无限滞留。为了避免这种情况的发生,我们可以通过setRemoveOnCancelPolicy方法设置移除策略,把参数removeOnCancel设为true可以在任务取消后立即从队列中移除。
  • sequencer是为相同延时的任务提供的顺序编号,保证任务之间的 FIFO 顺序。与 ScheduledFutureTask 内部的sequenceNumber参数作用一致。
3、构造函数

首先看下构造函数,ScheduledThreadPoolExecutor 内部有四个构造函数,这里我们只看这个最大构造灵活度的:

1
2
3
4
5
6
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}

构造函数都是通过super调用了ThreadPoolExecutor的构造,并且使用特定等待队列DelayedWorkQueue

4、核心方法——Schedule
1
2
3
4
5
6
7
8
9
10
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
long delay,
TimeUnit unit) {
if (callable == null || unit == null)
throw new NullPointerException();
RunnableScheduledFuture<V> t = decorateTask(callable,
new ScheduledFutureTask<V>(callable, triggerTime(delay, unit)));//构造ScheduledFutureTask任务
delayedExecute(t);//任务执行主方法
return t;
}

说明:schedule主要用于执行一次性(延迟)任务。函数执行逻辑分两步:

  • 封装 Callable/Runnable: 首先通过triggerTime计算任务的延迟执行时间,然后通过 ScheduledFutureTask 的构造函数把 Runnable/Callable 任务构造为ScheduledThreadPoolExecutor可以执行的任务类型,最后调用decorateTask方法执行用户自定义的逻辑;decorateTask是一个用户可自定义扩展的方法,默认实现下直接返回封装的RunnableScheduledFuture任务,源码如下:

    • protected <V> RunnableScheduledFuture<V> decorateTask(
          Runnable runnable, RunnableScheduledFuture<V> task) {
          return task;
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17

      - `执行任务`:**通过delayedExecute实现**。下面我们来详细分析:

      - ```java
      private void delayedExecute(RunnableScheduledFuture<?> task) {
      if (isShutdown())
      reject(task);//池已关闭,执行拒绝策略
      else {
      super.getQueue().add(task);//任务入队
      if (isShutdown() &&
      !canRunInCurrentRunState(task.isPeriodic()) &&//判断run-after-shutdown参数
      remove(task))//移除任务
      task.cancel(false);
      else
      ensurePrestart();//启动一个新的线程等待任务
      }
      }

说明:delayedExecute是执行任务的主方法,方法执行逻辑如下:

  • 如果池已关闭(ctl >= SHUTDOWN),执行任务拒绝策略

  • 池正在运行,首先把任务入队排序;然后重新检查池的关闭状态,执行如下逻辑:

    1. A如果池正在运行,或者 run-after-shutdown 参数值为true,则调用父类方法ensurePrestart启动一个新的线程等待执行任务。ensurePrestart源码如下:

      • void ensurePrestart() {
            int wc = workerCountOf(ctl.get());
            if (wc < corePoolSize)
                addWorker(null, true);
            else if (wc == 0)
                addWorker(null, false);
        }
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55

        - ensurePrestart是父类 ThreadPoolExecutor 的方法,用于启动一个新的工作线程等待执行任务,即使corePoolSize为0也会安排一个新线程。

        2. `B`:**如果池已经关闭,并且 run-after-shutdown 参数值为false,则执行父类(ThreadPoolExecutor)方法remove移除队列中的指定任务,成功移除后调用ScheduledFutureTask.cancel取消任务**

        ##### 5、核心方法——scheduleAtFixedRate 和 scheduleWithFixedDelay

        ```java
        /**
        * 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
        * 之后每隔period执行一次,不等待第一次执行完成就开始计时
        */
        public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
        long initialDelay,
        long period,
        TimeUnit unit) {
        if (command == null || unit == null)
        throw new NullPointerException();
        if (period <= 0)
        throw new IllegalArgumentException();
        //构建RunnableScheduledFuture任务类型
        ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
        null,
        triggerTime(initialDelay, unit),//计算任务的延迟时间
        unit.toNanos(period));//计算任务的执行周期
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
        sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
        delayedExecute(t);//执行任务
        return t;
        }

        /**
        * 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
        * 在第一次执行完之后延迟delay后开始下一次执行
        */
        public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
        long initialDelay,
        long delay,
        TimeUnit unit) {
        if (command == null || unit == null)
        throw new NullPointerException();
        if (delay <= 0)
        throw new IllegalArgumentException();
        //构建RunnableScheduledFuture任务类型
        ScheduledFutureTask<Void> sft =
        new ScheduledFutureTask<Void>(command,
        null,
        triggerTime(initialDelay, unit),//计算任务的延迟时间
        unit.toNanos(-delay));//计算任务的执行周期
        RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
        sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
        delayedExecute(t);//执行任务
        return t;
        }

说明:scheduleAtFixedRate和scheduleWithFixedDelay方法的逻辑与schedule类似。

注意scheduleAtFixedRate和scheduleWithFixedDelay的区别: 乍一看两个方法一模一样,其实,在unit.toNanos这一行代码中还是有区别的:

  • 没错,scheduleAtFixedRate传的是正值,而scheduleWithFixedDelay传的则是负值,这个值就是 ScheduledFutureTask 的period属性。
  • 执行效果上也有区别:
    • 对于scheduleAtFixedRate来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么将会影响到间隔的时间——间隔时间无效,会等到任务执行完毕在执行下一个任务
    • 而对于scheduleWithFixedDelay来说:如果间隔时间小于线程执行任务的时间(例如:间隔时间为1s,然而线程要执行2s),那么间隔的时间会增加——间隔的时间(3s) = 设置的延迟时间(1s) + 代码的执行时间(2s),即:scheduleWithFixedDelay的时间间隔是从上一个任务结束时间来计算的
6、核心方法——shutdown()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public void shutdown() {
super.shutdown();
}
//取消并清除由于关闭策略不应该运行的所有任务
@Override void onShutdown() {
BlockingQueue<Runnable> q = super.getQueue();
//获取run-after-shutdown参数
boolean keepDelayed =
getExecuteExistingDelayedTasksAfterShutdownPolicy();
boolean keepPeriodic =
getContinueExistingPeriodicTasksAfterShutdownPolicy();
if (!keepDelayed && !keepPeriodic) {//池关闭后不保留任务
//依次取消任务
for (Object e : q.toArray())
if (e instanceof RunnableScheduledFuture<?>)
((RunnableScheduledFuture<?>) e).cancel(false);
q.clear();//清除等待队列
}
else {//池关闭后保留任务
// Traverse snapshot to avoid iterator exceptions
//遍历快照以避免迭代器异常
for (Object e : q.toArray()) {
if (e instanceof RunnableScheduledFuture) {
RunnableScheduledFuture<?> t =
(RunnableScheduledFuture<?>)e;
if ((t.isPeriodic() ? !keepPeriodic : !keepDelayed) ||
t.isCancelled()) { // also remove if already cancelled
//如果任务已经取消,移除队列中的任务
if (q.remove(t))
t.cancel(false);
}
}
}
}
tryTerminate(); //终止线程池
}

说明:池关闭方法调用了父类ThreadPoolExecutor的shutdown,具体分析见 ThreadPoolExecutor 篇。这里主要介绍以下在shutdown方法中调用的==关闭钩子onShutdown方法==,它的主要作用是在关闭线程池后取消并清除由于关闭策略不应该运行的所有任务,这里主要是根据 run-after-shutdown 参数(continueExistingPeriodicTasksAfterShutdown和executeExistingDelayedTasksAfterShutdown)来决定线程池关闭后是否关闭已经存在的任务

5、再深入理解

1、为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?

例如:

  1. 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。
  2. 此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。
2、Executors 提供了哪几种方法来构造 ScheduledThreadPoolExecutor?
  • newScheduledThreadPool:可指定核心线程数的线程池。
  • newSingleThreadScheduledExecutor:只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。

注意:newScheduledThreadPool(1, threadFactory) 不等价于newSingleThreadScheduledExecutor。

  • newSingleThreadScheduledExecutor创建的线程池保证内部只有一个线程执行任务,并且线程数不可扩展
  • 而通过newScheduledThreadPool(1, threadFactory)创建的线程池可以通过setCorePoolSize方法来修改核心线程数

6、ScheduledThreadPoolExecutor应用

需求:让每周四 18:00:00 定时执行任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class TestSchedule {

// 如何让每周四 18:00:00 定时执行任务?
public static void main(String[] args) {
// 获取当前时间
LocalDateTime now = LocalDateTime.now();
System.out.println(now);
// 获取周四时间
LocalDateTime time = now.withHour(18).withMinute(0).withSecond(0).withNano(0).with(DayOfWeek.THURSDAY);
// 如果 当前时间 > 本周周四,必须找到下周周四
if(now.compareTo(time) > 0) {
time = time.plusWeeks(1);
}
System.out.println(time);
// initailDelay 代表当前时间和周四的时间差
// period 一周的间隔时间
long initailDelay = Duration.between(now, time).toMillis();
long period = 1000 * 60 * 60 * 24 * 7;
ScheduledExecutorService pool = Executors.newScheduledThreadPool(1);
pool.scheduleAtFixedRate(() -> {
System.out.println("running...");
}, initailDelay, period, TimeUnit.MILLISECONDS);
}
}

19、Tomcat 线程池

1、概述

Tomcat 在哪里用到了线程池呢?——tomcat的连接器部分(Connector)(tomcat还有容器部分——负责servlet规范的)

image-20210812012018371

  • LimitLatch 用来限流,可以控制最大连接个数,类似 J.U.C 中的 Semaphore
  • Acceptor 只负责【接收新的 socket 连接】
  • Poller 只负责监听 socket channel 是否有【可读的 I/O 事件】
  • 一旦可读,封装一个任务对象(socketProcessor),提交给 Executor 线程池处理
  • Executor 线程池中的工作线程最终负责【处理请求】

Tomcat 线程池扩展了 ThreadPoolExecutor,行为稍有不同

  • 如果总线程数达到 maximumPoolSize
    • 这时不会立刻抛 RejectedExecutionException 异常
    • 而是再次尝试将任务放入队列,如果还失败,才抛出 RejectedExecutionException 异常

2、源码 tomcat-7.0.42

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
// 调用父类的execute方法
super.execute(command);
} catch (RejectedExecutionException rx) { // 出现异常,进行进一步的处理
if (super.getQueue() instanceof TaskQueue) {
// 得到任务队列
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
// 再次尝试将任务放入队列
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
// 失败,抛出异常
throw new RejectedExecutionException("Queue capacity is full.");
}
} catch (InterruptedException x) {
submittedCount.decrementAndGet();
Thread.interrupted();
throw new RejectedExecutionException(x);
}
} else {
submittedCount.decrementAndGet();
throw rx;
}
}
}

TaskQueue.java

1
2
3
4
5
6
7
public boolean force(Runnable  o, long timeout, TimeUnit unit) throws InterruptedException {
if ( parent.isShutdown() )
throw new RejectedExecutionException(
"Executor not running, can't force a command into the queue"
);
return super.offer(o,timeout,unit); //forces the item onto the queue, to be used if the task is rejected
}

3、Connector 配置

配置项 默认值 说明
acceptorThreadCount 1 acceptor 线程数量
pollerThreadCount 1 poller 线程数量
minSpareThreads 10 核心线程数,即 corePoolSize
maxThreads 200 最大线程数,即 maximumPoolSize
executor - Executor 名称,用来引用下面的 Executor

4、Executor 线程配置

配置项 默认值 说明
threadPriority 5 线程优先级
daemon true 是否守护线程
minSpareThreads 25 核心线程数,即 corePoolSize
maxThreads 200 最大线程数,即 maximumPoolSize
maxIdleTime 60000 线程生存时间,单位是毫秒,默认值即 1 分钟
maxQueueSize Integer.MAX_VALUE 队列长度
prestartminSpareThreads false 核心线程是否在服务器启动时启动

20、Fork/Join分支合并框架

ForkJoinPool 是JDK 7加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。

1、BAT大厂的面试问题

  • Fork/Join主要用来解决什么样的问题?
  • Fork/Join框架是在哪个JDK版本中引入的?
  • Fork/Join框架主要包含哪三个模块?模块之间的关系是怎么样的?
  • ForkJoinPool类继承关系?
  • ForkJoinTask抽象类继承关系?
    • 在实际运用中,我们一般都会继承 RecursiveTaskRecursiveActionCountedCompleter 来实现我们的业务需求,而不会直接继承 ForkJoinTask 类
  • 整个Fork/Join 框架的执行流程/运行机制是怎么样的?
  • 具体阐述Fork/Join的分治思想和work-stealing 实现方式?
  • 有哪些JDK源码中使用了Fork/Join思想?
  • 如何使用Executors工具类创建ForkJoinPool?
  • 写一个例子:用ForkJoin方式实现1+2+3+…+100000?
  • Fork/Join在使用时有哪些注意事项?结合JDK中的斐波那契数列实例具体说明

2、Fork/Join框架简介

Fork/Join框架是Java并发工具包中的一种可以将一个大任务拆分为很多小任务来异步执行的工具,自JDK1.7引入。

1、三个模块及关系

Fork/Join框架主要包含三个模块:

  • 任务对象:ForkJoinTask (包括RecursiveTaskRecursiveActionCountedCompleter)
  • 执行Fork/Join任务的线程:ForkJoinWorkerThread
  • 线程池:ForkJoinPool

这三者的关系是:ForkJoinPool可以通过池中的ForkJoinWorkerThread来处理ForkJoinTask任务。

1
2
3
4
5
6
7
8
9
10
11
// from 《A Java Fork/Join Framework》Dong Lea
Result solve(Problem problem) {
if (problem is small)
directly solve problem
else {
split problem into independent parts
fork new subtasks to solve each part
join all subtasks
compose result from subresults
}
}

ForkJoinPool 只接收 ForkJoinTask 任务(在实际使用中,也可以接收 Runnable/Callable 任务,但在真正运行时,也会把这些任务封装成 ForkJoinTask 类型的任务),RecursiveTask 是 ForkJoinTask 的子类,是一个可以递归执行的 ForkJoinTask,RecursiveAction 是一个无返回值的 RecursiveTask,CountedCompleter 在任务完成执行后会触发执行一个自定义的钩子函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// From JDK 7 doc. Class RecursiveTask<V>
class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) { this.n = n; }
Integer compute() {
if (n <= 1)
return n;
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}

在实际运用中,我们一般都会继承 RecursiveTaskRecursiveActionCountedCompleter 来实现我们的业务需求,而不会直接继承 ForkJoinTask 类。

2、核心思想:分治算法(Divide-and-Conquer)

分治算法(Divide-and-Conquer)把任务递归的拆分为各个子任务,这样可以更好的利用系统资源,尽可能的使用所有可用的计算能力来提升应用性能。首先看一下 Fork/Join 框架的任务运行机制:

img

Fork/Join 框架要完成两件事情:

  • Fork:把一个复杂任务进行分拆,大事化小
  • Join:把分拆任务的结果进行合并

image-20210729225650517

  1. 任务分割:首先 Fork/Join 框架需要把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割
  2. 执行任务并合并结果:分割的子任务分别放到双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都放在另外一个队列里,启动一个线程从队列里取数据,然后合并这些数据。
3、核心思想:work-stealing(工作窃取)算法

work-stealing(工作窃取)算法:线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务(如果不存在就阻塞等待)。这种特性使得 ForkJoinPool 在运行多个可以产生子任务的任务,或者是提交的许多小任务时效率更高。尤其是构建异步模型的 ForkJoinPool 时,对不需要合并(join)的事件类型任务也非常适用。

在 ForkJoinPool 中,线程池中每个工作线程(ForkJoinWorkerThread)都对应一个任务队列(WorkQueue),工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。

具体思路如下:

  • 每个线程都有自己的一个WorkQueue,该工作队列是一个双端队列。
  • 队列支持三个功能push、pop、poll
  • push/pop只能被队列的所有者线程调用,而poll可以被其他线程调用。
  • 划分的子任务调用fork时,都会被push到自己的队列中。
  • 默认情况下,工作线程从自己的双端队列获出任务并执行。
  • 当自己的队列为空时,线程随机从另一个线程的队列末尾调用poll方法窃取任务。

img

4、Fork/Join 框架的执行流程

上图可以看出ForkJoinPool 中的任务执行分两种:

  • 直接通过 FJP 提交的外部任务(external/submissions task),存放在 workQueues 的偶数槽位;
  • 通过内部 fork 分割的子任务(Worker task),存放在 workQueues 的奇数槽位。

那Fork/Join 框架的执行流程是什么样的?

img

3、Fork/Join类关系

1、ForkJoinPool继承关系

img

内部类介绍:

  • ForkJoinWorkerThreadFactory:内部线程工厂接口,用于创建工作线程ForkJoinWorkerThread
  • DefaultForkJoinWorkerThreadFactory:ForkJoinWorkerThreadFactory 的默认实现类
  • InnocuousForkJoinWorkerThreadFactory:实现了 ForkJoinWorkerThreadFactory,无许可线程工厂,当系统变量中有系统安全管理相关属性时,默认使用这个工厂创建工作线程。
  • EmptyTask:内部占位类,用于替换队列中 join 的任务。
  • ManagedBlocker:为 ForkJoinPool 中的任务提供扩展管理并行数的接口,一般用在可能会阻塞的任务(如在 Phaser 中用于等待 phase 到下一个generation)。
  • WorkQueue:ForkJoinPool 的核心数据结构,本质上是work-stealing 模式的双端任务队列,内部存放 ForkJoinTask 对象任务,使用 @Contented 注解修饰防止伪共享
    • 工作线程在运行中产生新的任务(通常是因为调用了 fork())时,此时可以把 WorkQueue 的数据结构视为一个栈,新的任务会放入栈顶(top 位);工作线程在处理自己工作队列的任务时,按照 LIFO 的顺序。
    • 工作线程在处理自己的工作队列同时,会尝试窃取一个任务(可能是来自于刚刚提交到 pool 的任务,或是来自于其他工作线程的队列任务),此时可以把 WorkQueue 的数据结构视为一个 FIFO 的队列,窃取的任务位于其他线程的工作队列的队首(base位)。
  • 伪共享状态:缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
2、ForkJoinTask继承关系

img

ForkJoinTask 实现了 Future 接口,说明它也是一个可取消的异步运算任务,实际上ForkJoinTask 是 Future 的轻量级实现,主要用在纯粹是计算的函数式任务或者操作完全独立的对象计算任务。fork 是主运行方法,用于异步执行;而 join 方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。 其内部类都比较简单,ExceptionNode 是用于存储任务执行期间的异常信息的单向链表;其余四个类是为 Runnable/Callable 任务提供的适配器类,用于把 Runnable/Callable 转化为 ForkJoinTask 类型的任务(因为 ForkJoinPool 只可以运行 ForkJoinTask 类型的任务)。

4、Fork/Join框架源码解析

分析思路:在对类层次结构有了解以后,我们先看下内部核心参数,然后分析上述流程图。会分4个部分:

  • 首先介绍任务的提交流程 - 外部任务(external/submissions task)提交;
  • 然后介绍任务的提交流程 - 子任务(Worker task)提交;
  • 再分析任务的执行过程(ForkJoinWorkerThread.run()到ForkJoinTask.doExec()这一部分);
  • 最后介绍任务的结果获取(ForkJoinTask.join()和ForkJoinTask.invoke())
1、ForkJoinPool
1、核心参数

在后面的源码解析中,我们会看到大量的位运算,这些位运算都是通过我们接下来介绍的一些常量参数来计算的。

例如,如果要更新活跃线程数,使用公式(UC_MASK & (c + AC_UNIT)) | (SP_MASK & c);c 代表当前 ctl,UC_MASK 和 SP_MASK 分别是高位和低位掩码,AC_UNIT 为活跃线程的增量数,使用(UC_MASK & (c + AC_UNIT))就可以计算出高32位,然后再加上低32位(SP_MASK & c),就拼接成了一个新的ctl。

这些运算的可读性很差,看起来有些复杂。在后面源码解析中有位运算的地方我都会加上注释,大家只需要了解它们的作用即可。

ForkJoinPool 与 内部类 WorkQueue 共享的一些常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Constants shared across ForkJoinPool and WorkQueue

// 限定参数
static final int SMASK = 0xffff; // 低位掩码,也是最大索引位
static final int MAX_CAP = 0x7fff; // 工作线程最大容量
static final int EVENMASK = 0xfffe; // 偶数低位掩码
static final int SQMASK = 0x007e; // workQueues 数组最多64个槽位

// ctl 子域和 WorkQueue.scanState 的掩码和标志位
static final int SCANNING = 1; // 标记是否正在运行任务
static final int INACTIVE = 1 << 31; // 失活状态 负数
static final int SS_SEQ = 1 << 16; // 版本戳,防止ABA问题

// ForkJoinPool.config 和 WorkQueue.config 的配置信息标记
static final int MODE_MASK = 0xffff << 16; // 模式掩码
static final int LIFO_QUEUE = 0; //LIFO队列
static final int FIFO_QUEUE = 1 << 16;//FIFO队列
static final int SHARED_QUEUE = 1 << 31; // 共享模式队列,负数

ForkJoinPool 中的相关常量和实例字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//  低位和高位掩码
private static final long SP_MASK = 0xffffffffL;
private static final long UC_MASK = ~SP_MASK;

// 活跃线程数
private static final int AC_SHIFT = 48;
private static final long AC_UNIT = 0x0001L << AC_SHIFT; //活跃线程数增量
private static final long AC_MASK = 0xffffL << AC_SHIFT; //活跃线程数掩码

// 工作线程数
private static final int TC_SHIFT = 32;
private static final long TC_UNIT = 0x0001L << TC_SHIFT; //工作线程数增量
private static final long TC_MASK = 0xffffL << TC_SHIFT; //掩码
private static final long ADD_WORKER = 0x0001L << (TC_SHIFT + 15); // 创建工作线程标志

// 池状态
private static final int RSLOCK = 1;
private static final int RSIGNAL = 1 << 1;
private static final int STARTED = 1 << 2;
private static final int STOP = 1 << 29;
private static final int TERMINATED = 1 << 30;
private static final int SHUTDOWN = 1 << 31;

// 实例字段
volatile long ctl; // 主控制参数
volatile int runState; // 运行状态锁
final int config; // 并行度|模式
int indexSeed; // 用于生成工作线程索引
volatile WorkQueue[] workQueues; // 主对象注册信息,workQueue
final ForkJoinWorkerThreadFactory factory;// 线程工厂
final UncaughtExceptionHandler ueh; // 每个工作线程的异常信息
final String workerNamePrefix; // 用于创建工作线程的名称
volatile AtomicLong stealCounter; // 偷取任务总数,也可作为同步监视器

/** 静态初始化字段 */
//线程工厂
public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;
//启动或杀死线程的方法调用者的权限
private static final RuntimePermission modifyThreadPermission;
// 公共静态pool
static final ForkJoinPool common;
//并行度,对应内部common池
static final int commonParallelism;
//备用线程数,在tryCompensate中使用
private static int commonMaxSpares;
//创建workerNamePrefix(工作线程名称前缀)时的序号
private static int poolNumberSequence;
//线程阻塞等待新的任务的超时值(以纳秒为单位),默认2秒
private static final long IDLE_TIMEOUT = 2000L * 1000L * 1000L; // 2sec
//空闲超时时间,防止timer未命中
private static final long TIMEOUT_SLOP = 20L * 1000L * 1000L; // 20ms
//默认备用线程数
private static final int DEFAULT_COMMON_MAX_SPARES = 256;
//阻塞前自旋的次数,用在在awaitRunStateLock和awaitWork中
private static final int SPINS = 0;
//indexSeed的增量
private static final int SEED_INCREMENT = 0x9e3779b9;

说明:ForkJoinPool 的内部状态都是通过一个64位的 long 型 变量ctl来存储,它由四个16位的子域组成:

  • AC:正在运行工作线程数减去目标并行度,高16位
  • TC:总工作线程数减去目标并行度,中高16位
  • SS:栈顶等待线程的版本计数和状态,中低16位
  • ID:栈顶 WorkQueue 在池中的索引(poolIndex),低16位

在后面的源码解析中,某些地方也提取了ctl的低32位(sp=(int)ctl)来检查工作线程状态,例如,当sp不为0时说明当前还有空闲工作线程。

2、ForkJoinPool.WoekQueue 中的相关属性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//初始队列容量,2的幂
static final int INITIAL_QUEUE_CAPACITY = 1 << 13;
//最大队列容量
static final int MAXIMUM_QUEUE_CAPACITY = 1 << 26; // 64M

// 实例字段
volatile int scanState; // Woker状态, <0: inactive; odd:scanning
int stackPred; // 记录前一个栈顶的ctl
int nsteals; // 偷取任务数
int hint; // 记录偷取者索引,初始为随机索引
int config; // 池索引和模式
volatile int qlock; // 1: locked, < 0: terminate; else 0
volatile int base; // 下一个poll操作的索引(栈底/队列头)
int top; // 下一个push操作的索引(栈顶/队列尾)
ForkJoinTask<?>[] array; // 任务数组
final ForkJoinPool pool; // the containing pool (may be null)
final ForkJoinWorkerThread owner; // 当前工作队列的工作线程,共享模式下为null
volatile Thread parker; // 调用park阻塞期间为owner,其他情况为null
volatile ForkJoinTask<?> currentJoin; // 记录被join过来的任务
volatile ForkJoinTask<?> currentSteal; // 记录从其他工作队列偷取过来的任务
2、ForkJoinTask
核心参数
1
2
3
4
5
6
7
8
/** 任务运行状态 */
volatile int status; // 任务运行状态
static final int DONE_MASK = 0xf0000000; // 任务完成状态标志位
static final int NORMAL = 0xf0000000; // must be negative
static final int CANCELLED = 0xc0000000; // must be < NORMAL
static final int EXCEPTIONAL = 0x80000000; // must be < CANCELLED
static final int SIGNAL = 0x00010000; // must be >= 1 << 16 等待信号
static final int SMASK = 0x0000ffff; // 低位掩码

5、Fork/Join框架源码解析

1、构造函数
1
2
3
4
5
6
7
8
9
10
11
public ForkJoinPool(int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode) {
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}

说明:在 ForkJoinPool 中我们可以自定义四个参数:

  • parallelism:并行度,默认为CPU数,最小为1
  • factory:工作线程工厂;
  • handler:处理工作线程运行任务时的异常情况类,默认为null;
  • asyncMode:是否为异步模式,默认为 false。如果为true,表示子任务的执行遵循 FIFO 顺序并且任务不能被合并(join),这种模式适用于工作线程只运行事件类型的异步任务

在多数场景使用时,如果没有太强的业务需求,我们一般直接使用 ForkJoinPool 中的common池,在JDK1.8之后提供了ForkJoinPool.commonPool()方法可以直接使用common池,来看一下它的构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
private static ForkJoinPool makeCommonPool() {
int parallelism = -1;
ForkJoinWorkerThreadFactory factory = null;
UncaughtExceptionHandler handler = null;
try { // ignore exceptions in accessing/parsing
String pp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.parallelism");//并行度
String fp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.threadFactory");//线程工厂
String hp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.exceptionHandler");//异常处理类
if (pp != null)
parallelism = Integer.parseInt(pp);
if (fp != null)
factory = ((ForkJoinWorkerThreadFactory) ClassLoader.
getSystemClassLoader().loadClass(fp).newInstance());
if (hp != null)
handler = ((UncaughtExceptionHandler) ClassLoader.
getSystemClassLoader().loadClass(hp).newInstance());
} catch (Exception ignore) {
}
if (factory == null) {
if (System.getSecurityManager() == null)
factory = defaultForkJoinWorkerThreadFactory;
else // use security-managed default
factory = new InnocuousForkJoinWorkerThreadFactory();
}
if (parallelism < 0 && // default 1 less than #cores
(parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
parallelism = 1;//默认并行度为1
if (parallelism > MAX_CAP)
parallelism = MAX_CAP;
return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
"ForkJoinPool.commonPool-worker-");
}

使用common pool的优点就是我们可以通过指定系统参数的方式定义“并行度、线程工厂和异常处理类”;并且它使用的是同步模式,也就是说可以支持任务合并(join)。

2、执行流程——外部任务(external/submissions task)提交

向 ForkJoinPool 提交任务有三种方式:

  • invoke()会等待任务计算完毕并返回计算结果;
  • execute()是直接向池提交一个任务来异步执行,无返回结果;
  • submit()也是异步执行,但是会返回提交的任务,在适当的时候可通过task.get()获取执行结果。

这三种提交方式都都是调用externalPush()方法来完成,所以接下来我们将从externalPush()方法开始逐步分析外部任务的执行过程。

1、externalPush(ForkJoinTask<?> task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//添加给定任务到submission队列中
final void externalPush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue q;
int m;
int r = ThreadLocalRandom.getProbe();//探针值,用于计算WorkQueue槽位索引
int rs = runState;
if ((ws = workQueues) != null && (m = (ws.length - 1)) >= 0 &&
(q = ws[m & r & SQMASK]) != null && r != 0 && rs > 0 && //获取随机偶数槽位的workQueue
U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定workQueue
ForkJoinTask<?>[] a;
int am, n, s;
if ((a = q.array) != null &&
(am = a.length - 1) > (n = (s = q.top) - q.base)) {
int j = ((am & s) << ASHIFT) + ABASE;//计算任务索引位置
U.putOrderedObject(a, j, task);//任务入列
U.putOrderedInt(q, QTOP, s + 1);//更新push slot
U.putIntVolatile(q, QLOCK, 0);//解除锁定
if (n <= 1)
signalWork(ws, q);//任务数小于1时尝试创建或激活一个工作线程
return;
}
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
externalSubmit(task);//初始化workQueues及相关属性
}

首先说明一下externalPush和externalSubmit两个方法的联系:它们的作用都是把任务放到队列中等待执行。不同的是,externalSubmit可以说是完整版的externalPush,在任务首次提交时,需要初始化workQueues及其他相关属性,这个初始化操作就是externalSubmit来完成的;而后再向池中提交的任务都是通过简化版的externalSubmit-externalPush来完成。

externalPush的执行流程很简单:

  1. 首先找到一个随机偶数槽位的 workQueue,
  2. 然后把任务放入这个 workQueue 的任务数组中,并更新top位。
  3. 如果队列的剩余任务数小于1,则尝试创建或激活一个工作线程来运行任务(防止在externalSubmit初始化时发生异常导致工作线程创建失败)。
2、externalSubmit(ForkJoinTask<?> task)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
//任务提交
private void externalSubmit(ForkJoinTask<?> task) {
//初始化调用线程的探针值,用于计算WorkQueue索引
int r; // initialize caller's probe
if ((r = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit();
r = ThreadLocalRandom.getProbe();
}
for (; ; ) {
WorkQueue[] ws;
WorkQueue q;
int rs, m, k;
boolean move = false;
if ((rs = runState) < 0) {// 池已关闭
tryTerminate(false, false); // help terminate
throw new RejectedExecutionException();
}
//初始化workQueues
else if ((rs & STARTED) == 0 || // initialize
((ws = workQueues) == null || (m = ws.length - 1) < 0)) {
int ns = 0;
rs = lockRunState();//锁定runState
try {
//初始化
if ((rs & STARTED) == 0) {
//初始化stealCounter
U.compareAndSwapObject(this, STEALCOUNTER, null,
new AtomicLong());
//创建workQueues,容量为2的幂次方
// create workQueues array with size a power of two
int p = config & SMASK; // ensure at least 2 slots
int n = (p > 1) ? p - 1 : 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
n = (n + 1) << 1;
workQueues = new WorkQueue[n];
ns = STARTED;
}
} finally {
unlockRunState(rs, (rs & ~RSLOCK) | ns);//解锁并更新runState
}
} else if ((q = ws[k = r & m & SQMASK]) != null) {//获取随机偶数槽位的workQueue
if (q.qlock == 0 && U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定 workQueue
ForkJoinTask<?>[] a = q.array;//当前workQueue的全部任务
int s = q.top;
boolean submitted = false; // initial submission or resizing
try { // locked version of push
if ((a != null && a.length > s + 1 - q.base) ||
(a = q.growArray()) != null) {//扩容
int j = (((a.length - 1) & s) << ASHIFT) + ABASE;
U.putOrderedObject(a, j, task);//放入给定任务
U.putOrderedInt(q, QTOP, s + 1);//修改push slot
submitted = true;
}
} finally {
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
if (submitted) {//任务提交成功,创建或激活工作线程
signalWork(ws, q);//创建或激活一个工作线程来运行任务
return;
}
}
move = true; // move on failure 操作失败,重新获取探针值
} else if (((rs = runState) & RSLOCK) == 0) { // create new queue
q = new WorkQueue(this, null);
q.hint = r;
q.config = k | SHARED_QUEUE;
q.scanState = INACTIVE;
rs = lockRunState(); // publish index
if (rs > 0 && (ws = workQueues) != null &&
k < ws.length && ws[k] == null)
ws[k] = q; // 更新索引k位值的workQueue
//else terminated
unlockRunState(rs, rs & ~RSLOCK);
} else
move = true; // move if busy
if (move)
r = ThreadLocalRandom.advanceProbe(r);//重新获取线程探针值
}
}

说明:externalSubmit是externalPush的完整版本,主要用于第一次提交任务时初始化workQueues及相关属性,并且提交给定任务到队列中。具体执行步骤如下:

  • 如果池为终止状态(runState<0),调用tryTerminate来终止线程池,并抛出任务拒绝异常;
  • 如果尚未初始化,就为 FJP 执行初始化操作:初始化stealCounter、创建workerQueues,然后继续自旋;
  • 初始化完成后,执行在externalPush中相同的操作:获取 workQueue,放入指定任务。任务提交成功后调用signalWork方法创建或激活线程;
  • 如果在步骤3中获取到的 workQueue 为null,会在这一步中创建一个 workQueue,创建成功继续自旋执行第三步操作;
  • 如果非上述情况,或者有线程争用资源导致获取锁失败,就重新获取线程探针值继续自旋。
3、signalWork(WorkQueue[] ws, WorkQueue q)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
final void signalWork(WorkQueue[] ws, WorkQueue q) {
long c;
int sp, i;
WorkQueue v;
Thread p;
while ((c = ctl) < 0L) { // too few active
if ((sp = (int) c) == 0) { // no idle workers
if ((c & ADD_WORKER) != 0L) // too few workers
tryAddWorker(c);//工作线程太少,添加新的工作线程
break;
}
if (ws == null) // unstarted/terminated
break;
if (ws.length <= (i = sp & SMASK)) // terminated
break;
if ((v = ws[i]) == null) // terminating
break;
//计算ctl,加上版本戳SS_SEQ避免ABA问题
int vs = (sp + SS_SEQ) & ~INACTIVE; // next scanState
int d = sp - v.scanState; // screen CAS
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & v.stackPred);
if (d == 0 && U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs; // activate v
if ((p = v.parker) != null)
U.unpark(p);//唤醒阻塞线程
break;
}
if (q != null && q.base == q.top) // no more work
break;
}
}

说明:新建或唤醒一个工作线程,在externalPushexternalSubmitworkQueue.pushscan中调用。如果还有空闲线程,则尝试唤醒索引到的 WorkQueue 的parker线程;如果工作线程过少((ctl & ADD_WORKER) != 0L),则调用tryAddWorker添加一个新的工作线程。

4、tryAddWorker(long c)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private void tryAddWorker(long c) {
boolean add = false;
do {
long nc = ((AC_MASK & (c + AC_UNIT)) |
(TC_MASK & (c + TC_UNIT)));
if (ctl == c) {
int rs, stop; // check if terminating
if ((stop = (rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);
unlockRunState(rs, rs & ~RSLOCK);//释放锁
if (stop != 0)
break;
if (add) {
createWorker();//创建工作线程
break;
}
}
} while (((c = ctl) & ADD_WORKER) != 0L && (int)c == 0);
}

说明:尝试添加一个新的工作线程,首先更新ctl中的工作线程数,然后调用createWorker()创建工作线程。

5、createWorker()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private boolean createWorker() {
ForkJoinWorkerThreadFactory fac = factory;
Throwable ex = null;
ForkJoinWorkerThread wt = null;
try {
if (fac != null && (wt = fac.newThread(this)) != null) {
wt.start();
return true;
}
} catch (Throwable rex) {
ex = rex;
}
deregisterWorker(wt, ex);//线程创建失败处理
return false;
}

说明:createWorker首先通过线程工厂创一个新的ForkJoinWorkerThread,然后启动这个工作线程(wt.start())。如果期间发生异常,调用deregisterWorker处理线程创建失败的逻辑(deregisterWorker在后面再详细说明)。

ForkJoinWorkerThread 的构造函数如下:

1
2
3
4
5
6
protected ForkJoinWorkerThread(ForkJoinPool pool) {
// Use a placeholder until a useful name can be set in registerWorker
super("aForkJoinWorkerThread");
this.pool = pool;
this.workQueue = pool.registerWorker(this);
}

可以看到 ForkJoinWorkerThread 在构造时首先调用父类 Thread 的方法,然后为工作线程注册pool和workQueue,而workQueue的注册任务由ForkJoinPool.registerWorker来完成。

6、registerWorker()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
final WorkQueue registerWorker(ForkJoinWorkerThread wt) {
UncaughtExceptionHandler handler;
//设置为守护线程
wt.setDaemon(true); // configure thread
if ((handler = ueh) != null)
wt.setUncaughtExceptionHandler(handler);
WorkQueue w = new WorkQueue(this, wt);//构造新的WorkQueue
int i = 0; // assign a pool index
int mode = config & MODE_MASK;
int rs = lockRunState();
try {
WorkQueue[] ws;
int n; // skip if no array
if ((ws = workQueues) != null && (n = ws.length) > 0) {
//生成新建WorkQueue的索引
int s = indexSeed += SEED_INCREMENT; // unlikely to collide
int m = n - 1;
i = ((s << 1) | 1) & m; // Worker任务放在奇数索引位 odd-numbered indices
if (ws[i] != null) { // collision 已存在,重新计算索引位
int probes = 0; // step by approx half n
int step = (n <= 4) ? 2 : ((n >>> 1) & EVENMASK) + 2;
//查找可用的索引位
while (ws[i = (i + step) & m] != null) {
if (++probes >= n) {//所有索引位都被占用,对workQueues进行扩容
workQueues = ws = Arrays.copyOf(ws, n <<= 1);//workQueues 扩容
m = n - 1;
probes = 0;
}
}
}
w.hint = s; // use as random seed
w.config = i | mode;
w.scanState = i; // publication fence
ws[i] = w;
}
} finally {
unlockRunState(rs, rs & ~RSLOCK);
}
wt.setName(workerNamePrefix.concat(Integer.toString(i >>> 1)));
return w;
}

说明:registerWorker是 ForkJoinWorkerThread 构造器的回调函数,用于创建和记录工作线程的 WorkQueue。比较简单,就不多赘述了。注意在此为工作线程创建的 WorkQueue 是放在奇数索引的(代码行: i = ((s << 1) | 1) & m;)

7、小结

OK,外部任务的提交流程就先讲到这里。在createWorker()中启动工作线程后(wt.start()),当为线程分配到CPU执行时间片之后会运行 ForkJoinWorkerThread 的run方法开启线程来执行任务。工作线程执行任务的流程我们在讲完内部任务提交之后会统一讲解。

3、执行流程:子任务(Worker task)提交

子任务的提交相对比较简单,由任务的fork()方法完成。通过上面的流程图可以看到任务被分割(fork)之后调用了ForkJoinPool.WorkQueue.push()方法直接把任务放到队列中等待被执行。

1、ForkJoinTask.fork()
1
2
3
4
5
6
7
8
public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}

说明:如果当前线程是 Worker 线程,说明当前任务是fork分割的子任务,通过ForkJoinPool.workQueue.push()方法直接把任务放到自己的等待队列中;否则调用ForkJoinPool.externalPush()提交到一个随机的等待队列中(外部任务)。

2、ForkJoiPool.WorkQueue.push()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final void push(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
ForkJoinPool p;
int b = base, s = top, n;
if ((a = array) != null) { // ignore if queue removed
int m = a.length - 1; // fenced write for task visibility
U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task);
U.putOrderedInt(this, QTOP, s + 1);
if ((n = s - b) <= 1) {//首次提交,创建或唤醒一个工作线程
if ((p = pool) != null)
p.signalWork(p.workQueues, this);
} else if (n >= m)
growArray();
}
}

说明:首先把任务放入等待队列并更新top位;如果当前 WorkQueue 为新建的等待队列(top-base<=1),则调用signalWork方法为当前 WorkQueue 新建或唤醒一个工作线程;如果 WorkQueue 中的任务数组容量过小,则调用growArray()方法对其进行==两倍==扩容,growArray()方法源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final ForkJoinTask<?>[] growArray() {
ForkJoinTask<?>[] oldA = array;//获取内部任务列表
int size = oldA != null ? oldA.length << 1 : INITIAL_QUEUE_CAPACITY;
if (size > MAXIMUM_QUEUE_CAPACITY)
throw new RejectedExecutionException("Queue capacity exceeded");
int oldMask, t, b;
//新建一个两倍容量的任务数组
ForkJoinTask<?>[] a = array = new ForkJoinTask<?>[size];
if (oldA != null && (oldMask = oldA.length - 1) >= 0 &&
(t = top) - (b = base) > 0) {
int mask = size - 1;
//从老数组中拿出数据,放到新的数组中
do { // emulate poll from old array, push to new array
ForkJoinTask<?> x;
int oldj = ((b & oldMask) << ASHIFT) + ABASE;
int j = ((b & mask) << ASHIFT) + ABASE;
x = (ForkJoinTask<?>) U.getObjectVolatile(oldA, oldj);
if (x != null &&
U.compareAndSwapObject(oldA, oldj, x, null))
U.putObjectVolatile(a, j, x);
} while (++b != t);
}
return a;
}
3、小结

到此,两种任务的提交流程都已经解析完毕,下一节我们来一起看看任务提交之后是如何被运行的。

4、执行流程:任务执行

回到我们开始时的流程图,在ForkJoinPool .createWorker()方法中创建工作线程后,会启动工作线程,系统为工作线程分配到CPU执行时间片之后会执行 ForkJoinWorkerThread 的run()方法正式开始执行任务。

1、ForkJoinWorkerThread.run()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void run() {
if (workQueue.array == null) { // only run once
Throwable exception = null;
try {
onStart();//钩子方法,可自定义扩展
pool.runWorker(workQueue);
} catch (Throwable ex) {
exception = ex;
} finally {
try {
onTermination(exception);//钩子方法,可自定义扩展
} catch (Throwable ex) {
if (exception == null)
exception = ex;
} finally {
pool.deregisterWorker(this, exception);//处理异常
}
}
}
}

说明:方法很简单,在工作线程运行前后会调用自定义钩子函数(onStartonTermination),任务的运行则是调用了ForkJoinPool.runWorker()。如果全部任务执行完毕或者期间遭遇异常,则通过ForkJoinPool.deregisterWorker关闭工作线程并处理异常信息(deregisterWorker方法我们后面会详细讲解)。

2、ForkJoinPool.runWorker(WorkerQueue w)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
final void runWorker(WorkQueue w) {
w.growArray(); // allocate queue
int seed = w.hint; // initially holds randomization hint
int r = (seed == 0) ? 1 : seed; // avoid 0 for xorShift
for (ForkJoinTask<?> t; ; ) {
if ((t = scan(w, r)) != null)//扫描任务执行
w.runTask(t);
else if (!awaitWork(w, r))
break;
r ^= r << 13;
r ^= r >>> 17;
r ^= r << 5; // xorshift
}
}

说明:runWorker是 ForkJoinWorkerThread 的主运行方法,用来依次执行当前工作线程中的任务。

函数流程很简单:调用scan方法依次获取任务,然后调用WorkQueue .runTask运行任务;如果未扫描到任务,则调用awaitWork等待,直到工作线程/线程池终止或等待超时。

3、ForkJoinPool.scan(WorkQueue w, int r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
private ForkJoinTask<?> scan(WorkQueue w, int r) {
WorkQueue[] ws;
int m;
if ((ws = workQueues) != null && (m = ws.length - 1) > 0 && w != null) {
int ss = w.scanState; // initially non-negative
//初始扫描起点,自旋扫描
for (int origin = r & m, k = origin, oldSum = 0, checkSum = 0; ; ) {
WorkQueue q;
ForkJoinTask<?>[] a;
ForkJoinTask<?> t;
int b, n;
long c;
if ((q = ws[k]) != null) {//获取workQueue
if ((n = (b = q.base) - q.top) < 0 &&
(a = q.array) != null) { // non-empty
//计算偏移量
long i = (((a.length - 1) & b) << ASHIFT) + ABASE;
if ((t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i))) != null && //取base位置任务
q.base == b) {//stable
if (ss >= 0) { //scanning
if (U.compareAndSwapObject(a, i, t, null)) {//
q.base = b + 1;//更新base位
if (n < -1) // signal others
signalWork(ws, q);//创建或唤醒工作线程来运行任务
return t;
}
} else if (oldSum == 0 && // try to activate 尝试激活工作线程
w.scanState < 0)
tryRelease(c = ctl, ws[m & (int) c], AC_UNIT);//唤醒栈顶工作线程
}
//base位置任务为空或base位置偏移,随机移位重新扫描
if (ss < 0) // refresh
ss = w.scanState;
r ^= r << 1;
r ^= r >>> 3;
r ^= r << 10;
origin = k = r & m; // move and rescan
oldSum = checkSum = 0;
continue;
}
checkSum += b;//队列任务为空,记录base位
}
//更新索引k 继续向后查找
if ((k = (k + 1) & m) == origin) { // continue until stable
//运行到这里说明已经扫描了全部的 workQueues,但并未扫描到任务

if ((ss >= 0 || (ss == (ss = w.scanState))) &&
oldSum == (oldSum = checkSum)) {
if (ss < 0 || w.qlock < 0) // already inactive
break;// 已经被灭活或终止,跳出循环

//对当前WorkQueue进行灭活操作
int ns = ss | INACTIVE; // try to inactivate
long nc = ((SP_MASK & ns) |
(UC_MASK & ((c = ctl) - AC_UNIT)));//计算ctl为INACTIVE状态并减少活跃线程数
w.stackPred = (int) c; // hold prev stack top
U.putInt(w, QSCANSTATE, ns);//修改scanState为inactive状态
if (U.compareAndSwapLong(this, CTL, c, nc))//更新scanState为灭活状态
ss = ns;
else
w.scanState = ss; // back out
}
checkSum = 0;//重置checkSum,继续循环
}
}
}
return null;
}

说明:扫描并尝试偷取一个任务。使用w.hint进行随机索引 WorkQueue,也就是说并不一定会执行当前 WorkQueue 中的任务,而是偷取别的Worker的任务来执行。

函数的大概执行流程如下:

  • 取随机位置的一个 WorkQueue;
  • 获取base位的 ForkJoinTask,成功取到后更新base位并返回任务;如果取到的 WorkQueue 中任务数大于1,则调用signalWork创建或唤醒其他工作线程;
  • 如果当前工作线程处于不活跃状态(INACTIVE),则调用tryRelease尝试唤醒栈顶工作线程来执行。

tryRelease源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private boolean tryRelease(long c, WorkQueue v, long inc) {
int sp = (int) c, vs = (sp + SS_SEQ) & ~INACTIVE;
Thread p;
//ctl低32位等于scanState,说明可以唤醒parker线程
if (v != null && v.scanState == sp) { // v is at top of stack
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + inc)) | (SP_MASK & v.stackPred);
if (U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs;
if ((p = v.parker) != null)
U.unpark(p);//唤醒线程
return true;
}
}
return false;
}
  • 如果base位任务为空或发生偏移,则对索引位进行随机移位,然后重新扫描;
  • 如果扫描整个workQueues之后没有获取到任务,则设置当前工作线程为INACTIVE状态;然后重置checkSum,再次扫描一圈之后如果还没有任务则跳出循环返回null。
4、ForkJoinPool.awaitWork(WorkQueue w, int r)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
private boolean awaitWork(WorkQueue w, int r) {
if (w == null || w.qlock < 0) // w is terminating
return false;
for (int pred = w.stackPred, spins = SPINS, ss; ; ) {
if ((ss = w.scanState) >= 0)//正在扫描,跳出循环
break;
else if (spins > 0) {
r ^= r << 6;
r ^= r >>> 21;
r ^= r << 7;
if (r >= 0 && --spins == 0) { // randomize spins
WorkQueue v;
WorkQueue[] ws;
int s, j;
AtomicLong sc;
if (pred != 0 && (ws = workQueues) != null &&
(j = pred & SMASK) < ws.length &&
(v = ws[j]) != null && // see if pred parking
(v.parker == null || v.scanState >= 0))
spins = SPINS; // continue spinning
}
} else if (w.qlock < 0) // 当前workQueue已经终止,返回false recheck after spins
return false;
else if (!Thread.interrupted()) {//判断线程是否被中断,并清除中断状态
long c, prevctl, parkTime, deadline;
int ac = (int) ((c = ctl) >> AC_SHIFT) + (config & SMASK);//活跃线程数
if ((ac <= 0 && tryTerminate(false, false)) || //无active线程,尝试终止
(runState & STOP) != 0) // pool terminating
return false;
if (ac <= 0 && ss == (int) c) { // is last waiter
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
prevctl = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & pred);
int t = (short) (c >>> TC_SHIFT); // shrink excess spares
if (t > 2 && U.compareAndSwapLong(this, CTL, c, prevctl))//总线程过量
return false; // else use timed wait
//计算空闲超时时间
parkTime = IDLE_TIMEOUT * ((t >= 0) ? 1 : 1 - t);
deadline = System.nanoTime() + parkTime - TIMEOUT_SLOP;
} else
prevctl = parkTime = deadline = 0L;
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this); // emulate LockSupport
w.parker = wt;//设置parker,准备阻塞
if (w.scanState < 0 && ctl == c) // recheck before park
U.park(false, parkTime);//阻塞指定的时间

U.putOrderedObject(w, QPARKER, null);
U.putObject(wt, PARKBLOCKER, null);
if (w.scanState >= 0)//正在扫描,说明等到任务,跳出循环
break;
if (parkTime != 0L && ctl == c &&
deadline - System.nanoTime() <= 0L &&
U.compareAndSwapLong(this, CTL, c, prevctl))//未等到任务,更新ctl,返回false
return false; // shrink pool
}
}
return true;
}

说明:回到runWorker方法,如果scan方法未扫描到任务,会调用awaitWork等待获取任务。函数的具体执行流程大家看源码,这里简单说一下:

  • 在等待获取任务期间,如果工作线程或线程池已经终止则直接返回false。
  • 如果当前无 active 线程,尝试终止线程池并返回false,如果终止失败并且当前是最后一个等待的 Worker,就阻塞指定的时间(IDLE_TIMEOUT);
  • 等到届期或被唤醒后如果发现自己是scanning(scanState >= 0)状态,说明已经等到任务,跳出等待返回true继续 scan,否则的更新ctl并返回false。
5、WorkQueue.runTask()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
final void runTask(ForkJoinTask<?> task) {
if (task != null) {
scanState &= ~SCANNING; // mark as busy
(currentSteal = task).doExec();//更新currentSteal并执行任务
U.putOrderedObject(this, QCURRENTSTEAL, null); // release for GC
execLocalTasks();//依次执行本地任务
ForkJoinWorkerThread thread = owner;
if (++nsteals < 0) // collect on overflow
transferStealCount(pool);//增加偷取任务数
scanState |= SCANNING;
if (thread != null)
thread.afterTopLevelExec();//执行钩子函数
}
}

说明:在scan方法扫描到任务之后,调用WorkQueue.runTask()来执行获取到的任务,大概流程如下:

  • 标记scanState为正在执行状态;
  • 更新currentSteal为当前获取到的任务并执行它,任务的执行调用了ForkJoinTask.doExec()方法,

ForkJoinTask.doExec()方法的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//ForkJoinTask.doExec()
final int doExec() {
int s; boolean completed;
if ((s = status) >= 0) {
try {
completed = exec();//执行我们定义的任务
} catch (Throwable rex) {
return setExceptionalCompletion(rex);
}
if (completed)
s = setCompletion(NORMAL);
}
return s;
}

调用execLocalTasks依次执行当前WorkerQueue中的任务,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//执行并移除所有本地任务
final void execLocalTasks() {
int b = base, m, s;
ForkJoinTask<?>[] a = array;
if (b - (s = top - 1) <= 0 && a != null &&
(m = a.length - 1) >= 0) {
if ((config & FIFO_QUEUE) == 0) {//FIFO模式
for (ForkJoinTask<?> t; ; ) {
if ((t = (ForkJoinTask<?>) U.getAndSetObject
(a, ((m & s) << ASHIFT) + ABASE, null)) == null)//FIFO执行,取top任务
break;
U.putOrderedInt(this, QTOP, s);
t.doExec();//执行
if (base - (s = top - 1) > 0)
break;
}
} else
pollAndExecAll();//LIFO模式执行,取base任务
}
}
  • 更新偷取任务数;
  • 还原scanState并执行钩子函数。
6、ForkJoinPool.deregisterWorker(ForkJoinWorkerThread wt, Throwable ex)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
final void deregisterWorker(ForkJoinWorkerThread wt, Throwable ex) {
WorkQueue w = null;
//1.移除workQueue
if (wt != null && (w = wt.workQueue) != null) {//获取ForkJoinWorkerThread的等待队列
WorkQueue[] ws; // remove index from array
int idx = w.config & SMASK;//计算workQueue索引
int rs = lockRunState();//获取runState锁和当前池运行状态
if ((ws = workQueues) != null && ws.length > idx && ws[idx] == w)
ws[idx] = null;//移除workQueue
unlockRunState(rs, rs & ~RSLOCK);//解除runState锁
}
//2.减少CTL数
long c; // decrement counts
do {} while (!U.compareAndSwapLong
(this, CTL, c = ctl, ((AC_MASK & (c - AC_UNIT)) |
(TC_MASK & (c - TC_UNIT)) |
(SP_MASK & c))));
//3.处理被移除workQueue内部相关参数
if (w != null) {
w.qlock = -1; // ensure set
w.transferStealCount(this);
w.cancelAll(); // cancel remaining tasks
}
//4.如果线程未终止,替换被移除的workQueue并唤醒内部线程
for (;;) { // possibly replace
WorkQueue[] ws; int m, sp;
//尝试终止线程池
if (tryTerminate(false, false) || w == null || w.array == null ||
(runState & STOP) != 0 || (ws = workQueues) == null ||
(m = ws.length - 1) < 0) // already terminating
break;
//唤醒被替换的线程,依赖于下一步
if ((sp = (int)(c = ctl)) != 0) { // wake up replacement
if (tryRelease(c, ws[sp & m], AC_UNIT))
break;
}
//创建工作线程替换
else if (ex != null && (c & ADD_WORKER) != 0L) {
tryAddWorker(c); // create replacement
break;
}
else // don't need replacement
break;
}
//5.处理异常
if (ex == null) // help clean on way out
ForkJoinTask.helpExpungeStaleExceptions();
else // rethrow
ForkJoinTask.rethrow(ex);
}

说明:deregisterWorker方法用于工作线程运行完毕之后终止线程或处理工作线程异常,主要就是清除已关闭的工作线程或回滚创建线程之前的操作,并把传入的异常抛给 ForkJoinTask 来处理

7、小结

以上我们对任务的执行流程进行了说明,后面我们将继续介绍任务的结果获取(join/invoke)。

5、获取任务结果——ForkJoinTask.join()/ForkJoinTask.invoke()
  • join()

    • //合并任务结果
      public final V join() {
          int s;
          if ((s = doJoin() & DONE_MASK) != NORMAL)
              reportException(s);
          return getRawResult();
      }
      
      //join, get, quietlyJoin的主实现方法
      private int doJoin() {
          int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
          return (s = status) < 0 ? s :
          ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
              (w = (wt = (ForkJoinWorkerThread)t).workQueue).
              tryUnpush(this) && (s = doExec()) < 0 ? s :
          wt.pool.awaitJoin(w, this, 0L) :
          externalAwaitDone();
      }
      
      final int doExec() {
          int s; boolean completed; 
          if ((s = status) >= 0) {
              try {
                  completed = exec(); 
              } catch (Throwable rex) {
                  return setExceptionalCompletion(rex); 
              }
              if (completed) 
                  s = setCompletion(NORMAL); 
          }
          return s; 
      }
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
          
      - 它首先调用 doJoin 方法,通过 doJoin()方法得到当前任务的状态来判断返回什么结果,任务状态有 4 种: ==已完成(NORMAL)、被取消(CANCELLED)、信号(SIGNAL)和出现异常(EXCEPTIONAL)==

      - 如果任务状态是已完成,则直接返回任务结果。
      - 如果任务状态是被取消,则直接抛出 CancellationException
      - 如果任务状态是抛出异常,则直接抛出对应的异常

      - 在 doJoin()方法流程如下:

      1. 首先通过查看任务的状态,看任务是否已经执行完成,如果执行完成,则直接返回任务状态;
      2. 如果没有执行完,则从任务数组里取出任务并执行。
      3. 如果任务顺利执行完成,则设置任务状态为 NORMAL,如果出现异常,则记录异常,并将任务状态设置为 EXCEPTIONAL。

      - invoke()

      - ```java
      //执行任务,并等待任务完成并返回结果
      public final V invoke() {
      int s;
      if ((s = doInvoke() & DONE_MASK) != NORMAL)
      reportException(s);
      return getRawResult();
      }

      //invoke, quietlyInvoke的主实现方法
      private int doInvoke() {
      int s; Thread t; ForkJoinWorkerThread wt;
      return (s = doExec()) < 0 ? s :
      ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
      (wt = (ForkJoinWorkerThread)t).pool.
      awaitJoin(wt.workQueue, this, 0L) :
      externalAwaitDone();
      }

说明:join()方法一把是在任务fork()之后调用,用来获取(或者叫“合并”)任务的执行结果。

ForkJoinTask的join()和invoke()方法都可以用来获取任务的执行结果(另外还有get方法也是调用了doJoin来获取任务结果,但是会响应运行时异常),它们对外部提交任务的执行方式一致,都是通过externalAwaitDone方法等待执行结果。

不同的是invoke()方法会直接执行当前任务;而join()方法则是在当前任务在队列 top 位时(通过tryUnpush方法判断)才能执行,如果当前任务不在 top 位或者任务执行失败调用ForkJoinPool.awaitJoin方法帮助执行或阻塞当前 join 任务。(所以在官方文档中建议了我们对ForkJoinTask任务的调用顺序,一对 fork-join操作一般按照如下顺序调用:a.fork(); b.fork(); b.join(); a.join();。因为任务 b 是后面进入队列,也就是说它是在栈顶的(top 位),在它fork()之后直接调用join()就可以直接执行而不会调用ForkJoinPool.awaitJoin方法去等待。)

在这些方法中,join()相对比较全面,所以之后的讲解我们将从join()开始逐步向下分析,首先看一下join()的执行流程:

img

后面的源码分析中,我们首先讲解比较简单的外部 join 任务(externalAwaitDone),然后再讲解内部 join 任务(从ForkJoinPool.awaitJoin()开始)。

1、ForkJoinTask.externalAwaitDone()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private int externalAwaitDone() {
//执行任务
int s = ((this instanceof CountedCompleter) ? // try helping
ForkJoinPool.common.externalHelpComplete( // CountedCompleter任务
(CountedCompleter<?>)this, 0) :
ForkJoinPool.common.tryExternalUnpush(this) ? doExec() : 0); // ForkJoinTask任务
if (s >= 0 && (s = status) >= 0) {//执行失败,进入等待
boolean interrupted = false;
do {
if (U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) { //更新state
synchronized (this) {
if (status >= 0) {//SIGNAL 等待信号
try {
wait(0L);
} catch (InterruptedException ie) {
interrupted = true;
}
}
else
notifyAll();
}
}
} while ((s = status) >= 0);
if (interrupted)
Thread.currentThread().interrupt();
}
return s;
}

说明:如果当前join为外部调用,则调用此方法执行任务,如果任务执行失败就进入等待。方法本身是很简单的,需要注意的是对不同的任务类型分两种情况

  • 如果我们的任务为 CountedCompleter 类型的任务,则调用externalHelpComplete方法来执行任务。
  • 其他类型的 ForkJoinTask 任务调用tryExternalUnpush来执行

tryExternalUnpush的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//为外部提交者提供 tryUnpush 功能(给定任务在top位时弹出任务)
final boolean tryExternalUnpush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue w;
ForkJoinTask<?>[] a;
int m, s;
int r = ThreadLocalRandom.getProbe();
if ((ws = workQueues) != null && (m = ws.length - 1) >= 0 &&
(w = ws[m & r & SQMASK]) != null &&
(a = w.array) != null && (s = w.top) != w.base) {
long j = (((a.length - 1) & (s - 1)) << ASHIFT) + ABASE; //取top位任务
if (U.compareAndSwapInt(w, QLOCK, 0, 1)) { //加锁
if (w.top == s && w.array == a &&
U.getObject(a, j) == task &&
U.compareAndSwapObject(a, j, task, null)) { //符合条件,弹出
U.putOrderedInt(w, QTOP, s - 1); //更新top
U.putOrderedInt(w, QLOCK, 0); //解锁,返回true
return true;
}
U.compareAndSwapInt(w, QLOCK, 1, 0); //当前任务不在top位,解锁返回false
}
}
return false;
}

tryExternalUnpush的作用就是判断当前任务是否在top位,如果是则弹出任务,然后在externalAwaitDone中调用doExec()执行任务

2、ForkJoinPool.awaitJoin()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) {
int s = 0;
if (task != null && w != null) {
ForkJoinTask<?> prevJoin = w.currentJoin; //获取给定Worker的join任务
U.putOrderedObject(w, QCURRENTJOIN, task); //把currentJoin替换为给定任务
//判断是否为CountedCompleter类型的任务
CountedCompleter<?> cc = (task instanceof CountedCompleter) ?
(CountedCompleter<?>) task : null;
for (; ; ) {
if ((s = task.status) < 0) //已经完成|取消|异常 跳出循环
break;

if (cc != null)//CountedCompleter任务由helpComplete来完成join
helpComplete(w, cc, 0);
else if (w.base == w.top || w.tryRemoveAndExec(task)) //尝试执行
helpStealer(w, task); //队列为空或执行失败,任务可能被偷,帮助偷取者执行该任务

if ((s = task.status) < 0) //已经完成|取消|异常,跳出循环
break;
//计算任务等待时间
long ms, ns;
if (deadline == 0L)
ms = 0L;
else if ((ns = deadline - System.nanoTime()) <= 0L)
break;
else if ((ms = TimeUnit.NANOSECONDS.toMillis(ns)) <= 0L)
ms = 1L;

if (tryCompensate(w)) {//执行补偿操作
task.internalWait(ms);//补偿执行成功,任务等待指定时间
U.getAndAddLong(this, CTL, AC_UNIT);//更新活跃线程数
}
}
U.putOrderedObject(w, QCURRENTJOIN, prevJoin);//循环结束,替换为原来的join任务
}
return s;
}

说明:如果当前 join 任务不在Worker等待队列的top位,或者任务执行失败,调用此方法来帮助执行或阻塞当前 join 的任务。

函数执行流程如下:

  • 由于每次调用awaitJoin都会优先执行当前join的任务,所以首先会更新currentJoin为当前join任务;
  • 进入自旋:
    • 首先检查任务是否已经完成(通过task.status < 0判断),如果给定任务执行完毕|取消|异常,则跳出循环返回执行状态s;
    • 如果是 CountedCompleter 任务类型,调用helpComplete方法来完成join操作(后面笔者会开新篇来专门讲解CountedCompleter,本篇暂时不做详细解析);
    • 非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务;
    • 如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务);
    • 再次判断任务是否执行完毕(task.status < 0),如果任务执行失败,计算一个等待时间准备进行补偿操作;
    • 调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。在执行补偿期间,如果发现资源争用|池处于unstable状态|当前Worker已终止,则调用ForkJoinTask.internalWait()方法等待指定的时间,任务唤醒之后继续自旋。

ForkJoinTask.internalWait()源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
final void internalWait(long timeout) {
int s;
if ((s = status) >= 0 && // force completer to issue notify
U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) {//更新任务状态为SIGNAL(等待唤醒)
synchronized (this) {
if (status >= 0)
try { wait(timeout); } catch (InterruptedException ie) { }
else
notifyAll();
}
}
}

在awaitJoin中,我们总共调用了三个比较复杂的方法:tryRemoveAndExechelpStealertryCompensate,下面我们依次讲解。

3、WorkQueue.tryRemoveAndExec(ForkJoinTask<?> task)

非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
final boolean tryRemoveAndExec(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
int m, s, b, n;
if ((a = array) != null && (m = a.length - 1) >= 0 &&
task != null) {
while ((n = (s = top) - (b = base)) > 0) {
//从top往下自旋查找
for (ForkJoinTask<?> t; ; ) { // traverse from s to b
long j = ((--s & m) << ASHIFT) + ABASE;//计算任务索引
if ((t = (ForkJoinTask<?>) U.getObject(a, j)) == null) //获取索引到的任务
return s + 1 == top; // shorter than expected
else if (t == task) { //给定任务为索引任务
boolean removed = false;
if (s + 1 == top) { // pop
if (U.compareAndSwapObject(a, j, task, null)) { //弹出任务
U.putOrderedInt(this, QTOP, s); //更新top
removed = true;
}
} else if (base == b) // replace with proxy
removed = U.compareAndSwapObject(
a, j, task, new EmptyTask()); //join任务已经被移除,替换为一个占位任务
if (removed)
task.doExec(); //执行
break;
} else if (t.status < 0 && s + 1 == top) { //给定任务不是top任务
if (U.compareAndSwapObject(a, j, t, null)) //弹出任务
U.putOrderedInt(this, QTOP, s);//更新top
break; // was cancelled
}
if (--n == 0) //遍历结束
return false;
}
if (task.status < 0) //任务执行完毕
return false;
}
}
return true;
}

说明:从top位开始自旋向下找到给定任务,如果找到把它从当前 Worker 的任务队列中移除并执行它。

注意返回的参数:如果任务队列为空或者任务未执行完毕返回true;任务执行完毕返回false。

4、ForkJoinPool.helpStealer(WorkQueue w, ForkJoinTask<?> task)

如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
private void helpStealer(WorkQueue w, ForkJoinTask<?> task) {
WorkQueue[] ws = workQueues;
int oldSum = 0, checkSum, m;
if (ws != null && (m = ws.length - 1) >= 0 && w != null &&
task != null) {
do { // restart point
checkSum = 0; // for stability check
ForkJoinTask<?> subtask;
WorkQueue j = w, v; // v is subtask stealer
descent:
for (subtask = task; subtask.status >= 0; ) {
//1. 找到给定WorkQueue的偷取者v
for (int h = j.hint | 1, k = 0, i; ; k += 2) {//跳两个索引,因为Worker在奇数索引位
if (k > m) // can't find stealer
break descent;
if ((v = ws[i = (h + k) & m]) != null) {
if (v.currentSteal == subtask) {//定位到偷取者
j.hint = i;//更新stealer索引
break;
}
checkSum += v.base;
}
}
//2. 帮助偷取者v执行任务
for (; ; ) { // help v or descend
ForkJoinTask<?>[] a; //偷取者内部的任务
int b;
checkSum += (b = v.base);
ForkJoinTask<?> next = v.currentJoin;//获取偷取者的join任务
if (subtask.status < 0 || j.currentJoin != subtask ||
v.currentSteal != subtask) // stale
break descent; // stale,跳出descent循环重来
if (b - v.top >= 0 || (a = v.array) == null) {
if ((subtask = next) == null) //偷取者的join任务为null,跳出descent循环
break descent;
j = v;
break; //偷取者内部任务为空,可能任务也被偷走了;跳出本次循环,查找偷取者的偷取者
}
int i = (((a.length - 1) & b) << ASHIFT) + ABASE;//获取base偏移地址
ForkJoinTask<?> t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i));//获取偷取者的base任务
if (v.base == b) {
if (t == null) // stale
break descent; // stale,跳出descent循环重来
if (U.compareAndSwapObject(a, i, t, null)) {//弹出任务
v.base = b + 1; //更新偷取者的base位
ForkJoinTask<?> ps = w.currentSteal;//获取调用者偷来的任务
int top = w.top;
//首先更新给定workQueue的currentSteal为偷取者的base任务,然后执行该任务
//然后通过检查top来判断给定workQueue是否有自己的任务,如果有,
// 则依次弹出任务(LIFO)->更新currentSteal->执行该任务(注意这里是自己偷自己的任务执行)
do {
U.putOrderedObject(w, QCURRENTSTEAL, t);
t.doExec(); // clear local tasks too
} while (task.status >= 0 &&
w.top != top && //内部有自己的任务,依次弹出执行
(t = w.pop()) != null);
U.putOrderedObject(w, QCURRENTSTEAL, ps);//还原给定workQueue的currentSteal
if (w.base != w.top)//给定workQueue有自己的任务了,帮助结束,返回
return; // can't further help
}
}
}
}
} while (task.status >= 0 && oldSum != (oldSum = checkSum));
}
}

说明:如果队列为空或任务执行失败,说明任务可能被偷,调用此方法来帮助偷取者执行任务。

基本思想是:偷取者帮助我执行任务,我去帮助偷取者执行它的任务。 函数执行流程如下:

  1. 循环定位偷取者,由于Worker是在奇数索引位,所以每次会跳两个索引位。
  2. 定位到偷取者之后,更新调用者 WorkQueue 的hint为偷取者的索引,方便下次定位;
  3. 定位到偷取者后,开始帮助偷取者执行任务。从偷取者的base索引开始,每次偷取一个任务执行。
  4. 在帮助偷取者执行任务后,如果调用者发现本身已经有任务(w.top != top),则依次弹出自己的任务(LIFO顺序)并执行(也就是说自己偷自己的任务执行)。
5、ForkJoinPool.tryCompensate(WorkQueue w)

调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//执行补偿操作: 尝试缩减活动线程量,可能释放或创建一个补偿线程来准备阻塞
private boolean tryCompensate(WorkQueue w) {
boolean canBlock;
WorkQueue[] ws;
long c;
int m, pc, sp;
if (w == null || w.qlock < 0 || // caller terminating
(ws = workQueues) == null || (m = ws.length - 1) <= 0 ||
(pc = config & SMASK) == 0) // parallelism disabled
canBlock = false; //调用者已终止
else if ((sp = (int) (c = ctl)) != 0) // release idle worker
canBlock = tryRelease(c, ws[sp & m], 0L);//唤醒等待的工作线程
else {//没有空闲线程
int ac = (int) (c >> AC_SHIFT) + pc; //活跃线程数
int tc = (short) (c >> TC_SHIFT) + pc;//总线程数
int nbusy = 0; // validate saturation
for (int i = 0; i <= m; ++i) { // two passes of odd indices
WorkQueue v;
if ((v = ws[((i << 1) | 1) & m]) != null) {//取奇数索引位
if ((v.scanState & SCANNING) != 0)//没有正在运行任务,跳出
break;
++nbusy;//正在运行任务,添加标记
}
}
if (nbusy != (tc << 1) || ctl != c)
canBlock = false; // unstable or stale
else if (tc >= pc && ac > 1 && w.isEmpty()) {//总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空,不需要补偿
long nc = ((AC_MASK & (c - AC_UNIT)) |
(~AC_MASK & c)); // uncompensated
canBlock = U.compareAndSwapLong(this, CTL, c, nc);//更新活跃线程数
} else if (tc >= MAX_CAP ||
(this == common && tc >= pc + commonMaxSpares))//超出最大线程数
throw new RejectedExecutionException(
"Thread limit exceeded replacing blocked worker");
else { // similar to tryAddWorker
boolean add = false;
int rs; // CAS within lock
long nc = ((AC_MASK & c) |
(TC_MASK & (c + TC_UNIT)));//计算总线程数
if (((rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);//更新总线程数
unlockRunState(rs, rs & ~RSLOCK);
//运行到这里说明活跃工作线程数不足,需要创建一个新的工作线程来补偿
canBlock = add && createWorker(); // throws on exception
}
}
return canBlock;
}

说明:具体的执行看源码及注释,这里我们简单总结一下需要和不需要补偿的几种情况:

  • 需要补偿
    • 调用者队列不为空,并且有空闲工作线程,这种情况会唤醒空闲线程(调用tryRelease方法)
    • 池尚未停止,活跃线程数不足,这时会新建一个工作线程(调用createWorker方法)
  • 不需要补偿
    • 调用者已终止或池处于不稳定状态
    • 总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空

6、Fork/Join的陷阱与注意事项

使用Fork/Join框架时,需要注意一些陷阱, 在下面 斐波那契数列例子中你将看到示例。

1、避免不必要的fork()

划分成两个子任务后,不要同时调用两个子任务的fork()方法。

表面上看上去两个子任务都fork(),然后join()两次似乎更自然。但事实证明,直接调用compute()效率更高。因为直接调用子任务的compute()方法实际上就是在当前的工作线程进行了计算(线程重用),这比“将子任务提交到工作队列,线程又从工作队列中拿任务”快得多

当一个大任务被划分成两个以上的子任务时,尽可能使用前面说到的三个衍生的invokeAll方法,因为使用它们能避免不必要的fork()。

2、注意fork()、compute()、join()的顺序

为了两个任务并行,三个方法的调用顺序需要万分注意。

1
2
3
4
right.fork(); // 计算右边的任务
long leftAns = left.compute(); // 计算左边的任务(同时右边任务也在计算)
long rightAns = right.join(); // 等待右边的结果
return leftAns + rightAns;

如果我们写成:

1
2
3
4
left.fork(); // 计算完左边的任务
long leftAns = left.join(); // 等待左边的计算结果
long rightAns = right.compute(); // 再计算右边的任务
return leftAns + rightAns;

或者:

1
2
3
4
long rightAns = right.compute(); // 计算完右边的任务
left.fork(); // 再计算左边的任务
long leftAns = left.join(); // 等待左边的计算结果
return leftAns + rightAns;
3、选择合适的子任务粒度

选择划分子任务的粒度(顺序执行的阈值)很重要,因为使用Fork/Join框架并不一定比顺序执行任务的效率高:如果任务太大,则无法提高并行的吞吐量;如果任务太小,子任务的调度开销可能会大于并行计算的性能提升,我们还要考虑创建子任务、fork()子任务、线程调度以及合并子任务处理结果的耗时以及相应的内存消耗。

官方文档给出的粗略经验是:任务应该执行100~10000个基本的计算步骤。决定子任务的粒度的最好办法是实践,通过实际测试结果来确定这个阈值才是“上上策”。

和其他Java代码一样,Fork/Join框架测试时需要“预热”或者说执行几遍才会被JIT(Just-in-time)编译器优化,所以测试性能之前跑几遍程序很重要。

4、避免重量级任务划分与结果合并

Fork/Join的很多使用场景都用到数组或者List等数据结构,子任务在某个分区中运行,最典型的例子如并行排序和并行查找。拆分子任务以及合并处理结果的时候,应该尽量避免System.arraycopy这样耗时耗空间的操作,从而最小化任务的处理开销。

7、再深入理解

1、有哪些JDK源码中使用了Fork/Join思想

我们常用的数组工具类 Arrays 在JDK 8之后新增的==并行排序方法(parallelSort)==就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的==函数式方法(如forEach等)==也有运用。

2、使用Executors工具类创建ForkJoinPool

Java8在Executors工具类中新增了两个工厂方法:

1
2
3
4
5
// parallelism定义并行级别
public static ExecutorService newWorkStealingPool(int parallelism);
// 默认并行级别为JVM可用的处理器个数
// Runtime.getRuntime().availableProcessors()
public static ExecutorService newWorkStealingPool();
3、关于Fork/Join异常处理

Java的受检异常机制一直饱受诟病,所以在ForkJoinTask的invoke()、join()方法及其衍生方法中都没有像get()方法那样抛出个ExecutionException的受检异常。

所以你可以在ForkJoinTask中看到内部把受检异常转换成了运行时异常

1
2
3
4
5
6
7
8
9
static void rethrow(Throwable ex) {
if (ex != null)
ForkJoinTask.<RuntimeException>uncheckedThrow(ex);
}

@SuppressWarnings("unchecked")
static <T extends Throwable> void uncheckedThrow(Throwable t) throws T {
throw (T)t; // rely on vacuous cast
}

==关于Java你不知道的10件事==中已经指出,JVM实际并不关心这个异常是受检异常还是运行时异常,受检异常这东西完全是给Java编译器用的:用于警告程序员这里有个异常没有处理。

但不可否认的是invoke、join()仍可能会抛出运行时异常,所以ForkJoinTask还提供了两个不提取结果和异常的方法quietlyInvoke()、quietlyJoin(),这两个方法允许你在所有任务完成后对结果和异常进行处理。

使用quitelyInvoke()quietlyJoin()时可以配合isCompletedAbnormally()isCompletedNormally()方法使用。

ForkJoinTask 在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以 ForkJoinTask 提供了 isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过 ForkJoinTask 的getException 方法获取异常。

getException 方法返回 Throwable 对象,如果任务被取消了则返回 CancellationException。如果任务没有完成或者没有抛出异常则返回 null。

8、一些Fork/Join例子

1、采用Fork/Join来异步计算1+2+3+……+10000的结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Test {
static final class SumTask extends RecursiveTask<Integer> {
private static final long serialVersionUID = 1L;

final int start; //开始计算的数
final int end; //最后计算的数

SumTask(int start, int end) {
this.start = start;
this.end = end;
}

@Override
protected Integer compute() {
//如果计算量小于1000,那么分配一个线程执行if中的代码块,并返回执行结果
if(end - start < 1000) {
System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end);
int sum = 0;
for(int i = start; i <= end; i++)
sum += i;
return sum;
}
//如果计算量大于1000,那么拆分为两个任务
SumTask task1 = new SumTask(start, (start + end) / 2);
SumTask task2 = new SumTask((start + end) / 2 + 1, end);
//执行任务
task1.fork();
task2.fork();
//获取任务执行的结果
return task1.join() + task2.join();
}
}

public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> task = new SumTask(1, 10000);
pool.submit(task);
System.out.println(task.get());
}
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ForkJoinPool-1-worker-1 开始执行: 1-625
ForkJoinPool-1-worker-7 开始执行: 6251-6875
ForkJoinPool-1-worker-6 开始执行: 5626-6250
ForkJoinPool-1-worker-10 开始执行: 3751-4375
ForkJoinPool-1-worker-13 开始执行: 2501-3125
ForkJoinPool-1-worker-8 开始执行: 626-1250
ForkJoinPool-1-worker-11 开始执行: 5001-5625
ForkJoinPool-1-worker-3 开始执行: 7501-8125
ForkJoinPool-1-worker-14 开始执行: 1251-1875
ForkJoinPool-1-worker-4 开始执行: 9376-10000
ForkJoinPool-1-worker-8 开始执行: 8126-8750
ForkJoinPool-1-worker-0 开始执行: 1876-2500
ForkJoinPool-1-worker-12 开始执行: 4376-5000
ForkJoinPool-1-worker-5 开始执行: 8751-9375
ForkJoinPool-1-worker-7 开始执行: 6876-7500
ForkJoinPool-1-worker-1 开始执行: 3126-3750
50005000
2、实现斐波那契数列

斐波那契数列: 1、1、2、3、5、8、13、21、34、…… 公式 : F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(4); // 最大并发数4
Fibonacci fibonacci = new Fibonacci(20);
long startTime = System.currentTimeMillis();
Integer result = forkJoinPool.invoke(fibonacci);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
//以下为官方API文档示例
static class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}

当然你也可以两个任务都fork,要注意的是两个任务都fork的情况,必须按照f1.fork(),f2.fork(), f2.join(),f1.join()这样的顺序,不然有性能问题,详见上面注意事项中的说明。

官方API文档是这样写到的,所以平日用invokeAll就好了。invokeAll会把传入的任务的第一个交给当前线程来执行,其他的任务都fork加入工作队列,这样等于利用当前线程也执行任务了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
// ...
Fibonacci f1 = new Fibonacci(n - 1);
Fibonacci f2 = new Fibonacci(n - 2);
invokeAll(f1,f2);
return f2.join() + f1.join();
}

public static void invokeAll(ForkJoinTask<?>... tasks) {
Throwable ex = null;
int last = tasks.length - 1;
for (int i = last; i >= 0; --i) {
ForkJoinTask<?> t = tasks[i];
if (t == null) {
if (ex == null)
ex = new NullPointerException();
}
else if (i != 0) //除了第一个都fork
t.fork();
else if (t.doInvoke() < NORMAL && ex == null) //留一个自己执行
ex = t.getException();
}
for (int i = 1; i <= last; ++i) {
ForkJoinTask<?> t = tasks[i];
if (t != null) {
if (ex != null)
t.cancel(false);
else if (t.doJoin() < NORMAL)
ex = t.getException();
}
}
if (ex != null)
rethrow(ex);
}

21、CompletableFuture异步回调

1、CompletableFuture 简介

CompletableFuture 在 Java 里面被用于异步编程,异步通常意味着非阻塞, 可以使得我们的任务单独运行在与主线程分离的其他线程中,并且通过回调可以在主线程中得到异步任务的执行状态,是否完成,和是否异常等信息。

CompletableFuture 实现了 Future, CompletionStage 接口:

  • 实现了 Future 接口就可以兼容现在有线程池框架
  • 而 CompletionStage 接口才是异步编程的接口抽象,里面定义多种异步方法

通过这两者集合,从而打造出了强大的CompletableFuture 类。

2、Future 与 CompletableFuture

Futrue 在 Java 里面,通常用来表示一个异步任务的引用,比如我们将任务提交到线程池里面,然后我们会得到一个 Futrue,在 Future 里面有 isDone 方法来 判断任务是否处理结束,还有 get 方法可以一直阻塞直到任务结束然后获取结果,但整体来说这种方式,还是同步的,因为需要客户端不断阻塞等待或者不断轮询才能知道任务是否完成。

3、Future 的主要缺点

  1. 不支持手动完成
    • 我提交了一个任务,但是执行太慢了,我通过其他路径已经获取到了任务结果, 现在没法把这个任务结果通知到正在执行的线程,所以必须主动取消或者一直等待它执行完成
  2. 不支持进一步的非阻塞调用
    • 通过 Future 的 get 方法会一直阻塞到任务完成,但是想在获取任务之后执行额外的任务,因为 Future 不支持回调函数,所以无法实现这个功能
  3. 不支持链式调用
    • 对于 Future 的执行结果,我们想继续传到下一个 Future 处理使用,从而形成一个链式的 pipline 调用,这在 Future 中是没法实现的。
  4. 不支持多个 Future 合并
    • 比如我们有 10 个 Future 并行执行,我们想在所有的 Future 运行完毕之后, 执行某些函数,是没法通过 Future 实现的。
  5. 不支持异常处理
    • Future 的 API 没有任何的异常处理的 api,所以在异步运行时,如果出了问题是不好定位的。

4、CompletableFuture的使用

1、CompletableFuture 入门

场景:主线程里面创建一个 CompletableFuture,然后主线程调用 get 方法会阻塞,最后我们在一个子线程中使其终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CompletableFutureTest {
public static void main(String[] args) throws ExecutionException, InterruptedException {
CompletableFuture<String> future = new CompletableFuture<>();
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + "子线程开始干活");
//子线程睡 5 秒
Thread.sleep(5000);
//在子线程中完成主线程
future.complete("success");
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "A").start();
//主线程调用 get 方法阻塞
System.out.println("主线程调用 get 方法获取结果为: " + future.get());
System.out.println("主线程完成,阻塞结束!!!!!!");
}
}

结果:

1
2
3
A子线程开始干活
主线程调用 get 方法获取结果为: success
主线程完成,阻塞结束!!!!!!
2、没有返回值的同步任务
1
2
3
4
5
6
7
8
9
10
//没有返回值的同步任务
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
//同步调用
CompletableFuture<Void> completableFuture1 = CompletableFuture.runAsync(()->{
System.out.println(Thread.currentThread().getName()+" : CompletableFuture1");
});
completableFuture1.get();
}
}

同步任务调用runAsync()方法,使用get()方法获取

3、有返回值的异步任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//异步调用和同步调用
public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
//异步调用
CompletableFuture<Integer> completableFuture2 = CompletableFuture.supplyAsync(()->{
System.out.println(Thread.currentThread().getName()+" : CompletableFuture2");
//模拟异常
int i = 10/0;
return 1024;
});
completableFuture2.whenComplete((t,u)->{
System.out.println("------t="+t);
System.out.println("------u="+u);
}).get();

}
}

异步调用使用supplyAsync()方法,可以通过whenComplete()获取。

其中supplyAsync()方法的参数是一个Supplier类,而Supplier类是一个函数式接口,可以使用lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier) {
return asyncSupplyStage(asyncPool, supplier);
}

@FunctionalInterface
public interface Supplier<T> {

/**
* Gets a result.
*
* @return a result
*/
T get();
}

其中whenComplete()的两个参数:

  • t:用来接收异步调用中正常返回的结果,此时的u返回null
  • u:用来接收异步调用过程中出现的异常,此时的t返回null

关于一些函数式接口的接口类:

  • supplier:提供者,特点:无中生有 :() -> 结果
  • function:函数,特点:一个参数一个结果 :(参数) -> 结果
    • BiFunction:两个参数一个结果 :(参数1,参数2) -> 结果
  • consumer:消费者,特点:一个参数没结果:(参数) -> void
    • BiConsumer:两个参数,没有结果:(参数1,参数2) -> void
4、线程依赖

当一个线程依赖另一个线程时,可以使用 thenApply 方法来把这两个线程串行化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
try {
System.out.println("加 10 任务开始");
num += 10;
} catch (Exception e) {
e.printStackTrace();
}
return num;
}).thenApply(integer -> {
return num * num;
});
Integer integer = future.get();
System.out.println("主线程结束, 子线程的结果为:" + integer);
}
}
1
2
3
主线程开始
加 10 任务开始
主线程结束, 子线程的结果为:400
5、消费处理结果

thenAccept 消费处理结果,接收任务的处理结果,并消费处理,无返回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture.supplyAsync(() -> {
try {
System.out.println("加 10 任务开始");
num += 10;
} catch (Exception e) {
e.printStackTrace();
}
return num;
}).thenApply(integer -> {
return num * num;
}).thenAccept(new Consumer<Integer>() {
@Override
public void accept(Integer integer) {
System.out.println("子线程全部处理完成,最后调用了 accept,结果为:" + integer);
}
});
}
}
1
2
3
主线程开始
加 10 任务开始
子线程全部处理完成,最后调用了 accept,结果为:400
6、异常处理

exceptionally 异常处理,出现异常时触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 1 / 0;
System.out.println("加 10 任务开始");
num += 10;
return num;
}).exceptionally(ex -> {
System.out.println(ex.getMessage());
return -1;
});
System.out.println(future.get());
}
}

结果:

1
2
3
主线程开始
java.lang.ArithmeticException: / by zero
-1

handle 类似于 thenAccept/thenRun 方法,是最后一步的处理调用,但是同时可以处理异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
int i = 1 / 0;
System.out.println("加 10 任务开始");
num += 10;
return num;
}).handle((i, ex) -> {
System.out.println("进入 handle 方法");
if (ex != null) {
System.out.println("发生了异常,内容为:" + ex.getMessage());
return -1;
} else {
System.out.println("正常完成,内容为: " + i);
return i;
}
});
System.out.println(future.get());
}
}

结果:

1
2
3
4
主线程开始
进入 handle 方法
发生了异常,内容为:java.lang.ArithmeticException: / by zero
-1
7、结果合并

thenCompose 合并两个有依赖关系的 CompletableFutures 的执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
//第一步加 10
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
//合并
CompletableFuture<Integer> future1 = future.thenCompose(i ->
//再来一个CompletableFuture
CompletableFuture.supplyAsync(() -> {
return i + 1;
}));
System.out.println(future.get());
System.out.println(future1.get());
}
}

结果:

1
2
3
4
主线程开始
加 10 任务开始
20
21

thenCombine 合并两个没有依赖关系的 CompletableFutures 任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CompletableFutureTest {
private static Integer num = 10;

public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
System.out.println("乘以 10 任务开始");
num = num * 10;
return num;
});
//合并两个结果
CompletableFuture<Object> future = job1.thenCombine(job2, (BiFunction<Integer, Integer, List<Integer>>) (a, b) -> {
List<Integer> list = new ArrayList<>();
list.add(a);
list.add(b);
return list;
});
System.out.println("合并结果为:" + future.get());
}
}

结果:

1
2
3
4
主线程开始
加 10 任务开始
乘以 10 任务开始
合并结果为:[20, 200]
8、合并多个任务的结果allOf 与anyOf

allOf:一系列独立的future 任务,等其所有的任务执行完后做一些事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class CompletableFutureTest {
private static Integer num = 10;
public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
List<CompletableFuture> list = new ArrayList<>();
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
System.out.println("加 10 任务开始");
num += 10;
return num;
});
list.add(job1);
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
System.out.println("乘以 10 任务开始");
num *= 10;
return num;
});
list.add(job2);
CompletableFuture<Integer> job3 = CompletableFuture.supplyAsync(() -> {
System.out.println("减以 10 任务开始");
num -= 10;
return num;
});
list.add(job3);
CompletableFuture<Integer> job4 = CompletableFuture.supplyAsync(() -> {
System.out.println("除以 10 任务开始");
num /= 10;
return num;
});
list.add(job4);
//多任务合并
List<Integer> collect = list.stream().map(CompletableFuture<Integer>::join).collect(Collectors.toList());
System.out.println(collect);
}
}
1
2
3
4
5
6
主线程开始
加 10 任务开始
乘以 10 任务开始
减以 10 任务开始
除以 10 任务开始
[20, 200, 190, 19]

anyOf:只要在多个future 里面有一个返回,整个任务就可以结束,而不需要等到每一个 future 结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class CompletableFutureTest {
private static Integer num = 10;
public static void main(String[] args) throws Exception {
System.out.println("主线程开始");
CompletableFuture<Integer>[] futures = new CompletableFuture[4];
//第一步加 10
CompletableFuture<Integer> job1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
System.out.println("加 10 任务开始");
num += 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 0;
}
});
futures[0] = job1;
CompletableFuture<Integer> job2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(2000);
System.out.println("乘以 10 任务开始");
num *= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 1;
}
});
futures[1] = job2;
CompletableFuture<Integer> job3 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(3000);
System.out.println("减以 10 任务开始");
num -= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 2;
}
});
futures[2] = job3;
CompletableFuture<Integer> job4 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(4000);
System.out.println("除以 10 任务开始");
num /= 10;
return num;
} catch (InterruptedException e) {
e.printStackTrace();
return 3;
}
});
futures[3] = job4;
CompletableFuture<Object> future = CompletableFuture.anyOf(futures);
System.out.println(future.get());
}
}

结果:

1
2
3
主线程开始
乘以 10 任务开始
100

22、Java 并发 - ThreadLocal详解

ThreadLocal是通过线程隔离的方式防止任务在共享资源上产生冲突,线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程都创建不同的存储。

1、BAT大厂的面试问题

  • 什么是ThreadLocal?用来解决什么问题的?
  • 说说你对ThreadLocal的理解
  • ThreadLocal是如何实现线程隔离的?
  • 为什么ThreadLocal会造成内存泄露?如何解决?
  • 还有哪些使用ThreadLocal的应用场景?

2、ThreadLocal简介

我们在==Java 并发 - 并发理论基础==总结过线程安全(是指广义上的共享资源访问安全性,因为线程隔离是通过副本保证本线程访问资源安全性,它不保证线程之间还存在共享关系的狭义上的安全性)的解决思路:

  • 互斥同步synchronizedReentrantLock
  • 非阻塞同步CASAtomicXXXX
  • 无同步方案栈封闭本地存储(Thread Local)可重入代码

这个章节将详细的讲讲 本地存储(Thread Local)。官网的解释是这样的:

his class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID) 该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。

总结而言:ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类;当使用ThreadLocal来维护变量时,ThreadLocal会为每个线程创建单独的变量副本,避免因多线程操作共享变量而导致的数据不一致的情况

3、ThreadLocal理解

提到ThreadLocal被提到应用最多的是session管理和数据库链接管理,这里以数据访问为例来理解ThreadLocal:

如下数据库管理类在单线程使用是没有任何问题的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ConnectionManager {
private static Connection connect = null;

public static Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}

public static void closeConnection() {
if (connect != null)
connect.close();
}
}

很显然,在多线程中使用会存在线程安全问题:

  1. 第一,这里面的2个方法都没有进行同步,很可能在openConnection方法中会多次创建connect;
  2. 第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用closeConnection关闭链接。

为了解决上述线程安全的问题,第一考虑:互斥同步

你可能会说,将这段代码的两个方法进行同步处理,并且在调用connect的地方需要进行同步处理,比如用Synchronized或者ReentrantLock互斥锁

这里再抛出一个问题:这地方到底需不需要将connect变量进行共享?

事实上,是不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。即改后的代码可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ConnectionManager {
private Connection connect = null;

public Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}

public void closeConnection() {
if (connect != null)
connect.close();
}
}

class Dao {
public void insert() {
ConnectionManager connectionManager = new ConnectionManager();
Connection connection = connectionManager.openConnection();

// 使用connection进行操作

connectionManager.closeConnection();
}
}

这样处理确实也没有任何问题,由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题

但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。由于在方法中需要频繁地开启和关闭数据库连接,这样不仅严重影响程序执行效率,还可能导致服务器压力巨大

这时候ThreadLocal登场了

那么这种情况下使用ThreadLocal是再适合不过的了,因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。下面就是网上出现最多的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class ConnectionManager {

private static final ThreadLocal<Connection> dbConnectionLocal = new ThreadLocal<Connection>() {
@Override
protected Connection initialValue() {
try {
return DriverManager.getConnection("", "", "");
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
};

public Connection getConnection() {
return dbConnectionLocal.get();
}
}

再注意下ThreadLocal的修饰符

ThreaLocal的JDK文档中说明:ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread。如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。

但是要注意,虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大

4、ThreadLocal原理

1、如何实现线程隔离

主要是用到了Thread对象中的一个ThreadLocalMap类型的变量threadLocals,负责存储当前线程的关于Connection的对象,dbConnectionLocal(以上述例子中为例) 这个变量为Key,以新建的Connection对象为Value;这样的话,线程第一次读取的时候如果不存在就会调用ThreadLocal的initialValue方法创建一个Connection对象并且返回。

具体关于为线程分配变量副本的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
  • 首先获取当前线程对象t,然后从线程t中获取到ThreadLocalMap的成员属性threadLocals
  • 如果当前线程的threadLocals已经初始化(即不为null) 并且存在以当前ThreadLocal对象为Key的值,则直接返回当前线程要获取的对象(本例中为Connection);
  • 如果当前线程的threadLocals已经初始化(即不为null)但是不存在以当前ThreadLocal对象为Key的的对象,那么重新创建一个Connection对象,并且添加到当前线程的threadLocals Map中,并返回;
  • 如果当前线程的threadLocals属性还没有被初始化,则重新创建一个ThreadLocalMap对象,并且创建一个Connection对象并添加到ThreadLocalMap对象中并返回。

如果存在则直接返回很好理解,那么对于如何初始化的代码又是怎样的呢?

1
2
3
4
5
6
7
8
9
10
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
  • 首先调用我们上面写的重载过后的initialValue方法,产生一个Connection对象
  • 继续查看当前线程的threadLocals是不是空的,如果ThreadLocalMap已被初始化,那么直接将产生的对象添加到ThreadLocalMap中,如果没有初始化,则创建并添加对象到其中;

同时,ThreadLocal还提供了直接操作Thread对象中的threadLocals的方法:

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

这样我们也可以不实现initialValue,将初始化工作放到DBConnectionFactory的getConnection方法中:

1
2
3
4
5
6
7
8
9
10
11
12
public Connection getConnection() {
Connection connection = dbConnectionLocal.get();
if (connection == null) {
try {
connection = DriverManager.getConnection("", "", "");
dbConnectionLocal.set(connection);
} catch (SQLException e) {
e.printStackTrace();
}
}
return connection;
}

那么我们看过代码之后就很清晰的知道了为什么ThreadLocal能够实现变量的多线程隔离了;其实就是用了Map的数据结构给当前线程缓存了,要使用的时候就从本线程的threadLocals对象中获取就可以了,key就是当前线程

当然了在当前线程下获取当前线程里面的Map里面的对象并操作肯定没有线程并发问题了,当然能做到变量的线程间隔离了;

现在我们知道了ThreadLocal到底是什么了,又知道了如何使用ThreadLocal以及其基本实现原理了,是不是就可以结束了呢?其实还有一个问题就是ThreadLocalMap是个什么对象,为什么要用这个对象呢?

2、ThreadLocalMap对象是什么

本质上来讲,它就是一个Map,但是这个ThreadLocalMap与我们平时见到的Map有点不一样:

  • 它没有实现Map接口
  • 它没有public的方法,最多有一个default的构造方法,因为这个ThreadLocalMap的方法仅仅在ThreadLocal类中调用,属于静态内部类
  • ThreadLocalMap的Entry实现继承了WeakReference<ThreadLocal<?>>
  • 该方法仅仅用了一个Entry数组来存储Key、Value;Entry并不是链表形式,而是每个bucket里面仅仅放一个Entry

要了解ThreadLocalMap的实现,我们先从入口开始,就是往该Map中添加一个值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

先进行简单的分析,对该代码表层意思进行解读:

  • 看下当前threadLocal的在数组中的索引位置 比如:i = 2,看 i = 2 位置上面的元素(Entry)的Key是否等于threadLocal 这个 Key,如果等于就很好说了,直接将该位置上面的Entry的Value替换成最新的就可以了;
  • 如果当前位置上面的 Entry 的 Key为空,说明ThreadLocal对象已经被回收了,那么就调用replaceStaleEntry
  • 如果清理完无用条目(ThreadLocal被回收的条目)、并且数组中的数据大小 > 阈值的时候对当前的Table进行重新哈希,所以,该HashMap是处理冲突检测的机制是向后移位,清除过期条目 最终找到合适的位置

了解完Set方法,后面就是Get方法了:

1
2
3
4
5
6
7
8
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

先找到ThreadLocal的索引位置,如果索引位置处的entry不为空并且键与threadLocal是同一个对象,则直接返回;否则去后面的索引位置继续查找。

5、ThreadLocal造成内存泄漏的问题

网上有这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadLocalDemo {
static class LocalVariable {
private Long[] a = new Long[1024 * 1024];
}

// (1)
final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES,
new LinkedBlockingQueue<>());
// (2)
final static ThreadLocal<LocalVariable> localVariable = new ThreadLocal<LocalVariable>();

public static void main(String[] args) throws InterruptedException {
// (3)
Thread.sleep(5000 * 4);
for (int i = 0; i < 50; ++i) {
poolExecutor.execute(new Runnable() {
public void run() {
// (4)
localVariable.set(new LocalVariable());
// (5)
System.out.println("use local varaible" + localVariable.get());
localVariable.remove();
}
});
}
// (6)
System.out.println("pool execute over");
}
}

如果用线程池来操作ThreadLocal 对象确实会造成内存泄露,因为对于线程池里面不会销毁的线程,里面总会存在着<ThreadLocal, LocalVariable>的强引用,因为final static 修饰的 ThreadLocal 并不会释放,而ThreadLocalMap 对于 Key 虽然是弱引用,但是强引用不会释放,弱引用当然也会一直有值,同时创建的LocalVariable对象也不会释放,就造成了内存泄露

如果LocalVariable对象不是一个大对象的话,其实泄露的并不严重,泄露的内存 = 核心线程数 * LocalVariable对象的大小;

所以,为了避免出现内存泄露的情况,ThreadLocal提供了一个清除线程中对象的方法,即 remove,其实内部实现就是调用 ThreadLocalMap 的remove方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

找到Key对应的Entry,并且清除Entry的Key(ThreadLocal)置空,随后清除过期的Entry即可避免内存泄露

6、再看ThreadLocal应用场景

1、每个线程维护了一个“序列号”

再回想上文说的,如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。

每个线程维护了一个“序列号”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SerialNum {
// The next serial number to be assigned
private static int nextSerialNum = 0;

private static ThreadLocal serialNum = new ThreadLocal() {
protected synchronized Object initialValue() {
return new Integer(nextSerialNum++);
}
};

public static int get() {
return ((Integer) (serialNum.get())).intValue();
}
}

2、Session的管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static final ThreadLocal threadSession = new ThreadLocal();  

public static Session getSession() throws InfrastructureException {
Session s = (Session) threadSession.get();
try {
if (s == null) {
s = getSessionFactory().openSession();
threadSession.set(s);
}
} catch (HibernateException ex) {
throw new InfrastructureException(ex);
}
return s;
}
3、在线程内部创建ThreadLocal

还有一种用法是在线程类内部创建ThreadLocal,基本步骤如下:

  • 在多线程的类(如ThreadDemo类)中,创建一个ThreadLocal对象threadXxx,用来保存线程间需要隔离处理的对象xxx。
  • 在ThreadDemo类中,创建一个获取要隔离访问的数据的方法getXxx(),在方法中判断,若ThreadLocal对象为null时候,应该new()一个隔离访问类型的对象,并强制转换为要应用的类型。
  • 在ThreadDemo类的run()方法中,通过调用getXxx()方法获取要操作的数据,这样可以保证每个线程对应一个数据对象,在任何时刻都操作的是这个对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class ThreadLocalTest implements Runnable{

ThreadLocal<Student> StudentThreadLocal = new ThreadLocal<Student>();

@Override
public void run() {
String currentThreadName = Thread.currentThread().getName();
System.out.println(currentThreadName + " is running...");
Random random = new Random();
int age = random.nextInt(100);
System.out.println(currentThreadName + " is set age: " + age);
Student Student = getStudentt(); //通过这个方法,为每个线程都独立的new一个Studentt对象,每个线程的的Studentt对象都可以设置不同的值
Student.setAge(age);
System.out.println(currentThreadName + " is first get age: " + Student.getAge());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println( currentThreadName + " is second get age: " + Student.getAge());

}

private Student getStudentt() {
Student Student = StudentThreadLocal.get();
if (null == Student) {
Student = new Student();
StudentThreadLocal.set(Student);
}
return Student;
}

public static void main(String[] args) {
ThreadLocalTest t = new ThreadLocalTest();
Thread t1 = new Thread(t,"Thread A");
Thread t2 = new Thread(t,"Thread B");
t1.start();
t2.start();
}

}

class Student{
int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
4、java开发手册中推荐的ThreadLocal

看看阿里巴巴 java 开发手册中推荐的 ThreadLocal 的用法:

1
2
3
4
5
6
7
8
9
10
11
import java.text.DateFormat;
import java.text.SimpleDateFormat;

public class DateUtils {
public static final ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(){
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd");
}
};
}

然后我们再要用到 DateFormat 对象的地方,这样调用:

1
DateUtils.df.get().format(new Date());

23、补充:阿姆达尔定律

阿姆达尔定律可以用来计算处理器平行运算之后效率提升的能力。阿姆达尔定律因Gene Amdal 在1967年提出这个定律而得名。绝大多数使用并行或并发系统的开发者有一种并发或并行可能会带来提速的感觉,甚至不知道阿姆达尔定律。不管怎样,了解阿姆达尔定律还是有用的。

我会首先以算术的方式介绍阿姆达尔定律定律,然后再用图表演示一下。

1、阿姆达尔定律定义

一个程序(或者一个算法)可以按照是否可以被并行化分为下面两个部分:

  • 可以被并行化的部分
  • 不可以被并行化的部分

假设一个程序处理磁盘上的文件。这个程序的一小部分用来扫描路径和在内存中创建文件目录。做完这些后,每个文件交个一个单独的线程去处理。扫描路径和创建文件目录的部分不可以被并行化,不过处理文件的过程可以。

程序串行(非并行)执行的总时间我们记为T。时间T包括不可以被并行和可以被并行部分的时间不可以被并行的部分我们记为B。那么可以被并行的部分就是T-B。下面的列表总结了这些定义:

  • T = 串行执行的总时间
  • B = 不可以并行的总时间
  • T-B = 并行部分的总时间

从上面可以得出:T = B + (T – B)

首先,这个看起来可能有一点奇怪,程序的可并行部分在上面这个公式中并没有自己的标识。然而,由于这个公式中可并行可以用总时间T 和 B(不可并行部分)表示出来,这个公式实际上已经从概念上得到了简化,也即是指以这种方式减少了变量的个数。

T-B 是可并行化的部分,以并行的方式执行可以提高程序的运行速度。可以提速多少取决于有多少线程或者多少个CPU来执行。线程或者CPU的个数我们记为N。可并行化部分被执行的最快时间可以通过下面的公式计算出来:(T – B ) / N 或者通过这种方式 (1 / N) * (T – B)。维基中使用的是第二种方式。

根据阿姆达尔定律,当一个程序的可并行部分使用N个线程或CPU执行时,执行的总时间为:T(N) = B + ( T – B ) / N

T(N)指的是在并行因子为N时的总执行时间。因此,T(1)就执行在并行因子为1时程序的总执行时间。使用T(1)代替T,阿姆达尔定律定律看起来像这样:T(N) = B + (T(1) – B) / N 表达的意思都是是一样的。

2、一个计算例子

为了更好的理解阿姆达尔定律,让我们来看一个计算的例子。执行一个程序的总时间设为1,程序的不可并行化占40%,按总时间1计算,就是0.4,可并行部分就是1 – 0.4 = 0.6。

在并行因子为2的情况下,程序的执行时间将会是:

1
2
3
4
T(2) = 0.4 + ( 1 - 0.4 ) / 2
= 0.4 + 0.6 / 2
= 0.4 + 0.3
= 0.7

在并行因子为5的情况下,程序的执行时间将会是:

1
2
3
4
T(5) = 0.4 + ( 1 - 0.4 ) / 5
= 0.4 + 0.6 / 6
= 0.4 + 0.12
= 0.52

3、阿姆达尔定律图示

为了更好地理解阿姆达尔定律,我会尝试演示这个定律是如何诞生的。

首先,一个程序可以被分割为两部分,一部分为不可并行部分B,一部分为可并行部分1 – B。如下图:

image-20210814195444348

在顶部被带有分割线的那条直线代表总时间 T(1)。

下面你可以看到在并行因子为2的情况下的执行时间:

image-20210814195510264

并行因子为3的情况:

image-20210814195531931

4、优化算法

从阿姆达尔定律可以看出,程序的可并行化部分可以通过使用更多的硬件(更多的线程或CPU)运行更快。对于不可并行化的部分,只能通过优化代码来达到提速的目的。因此,你可以通过优化不可并行化部分来提高你的程序的运行速度和并行能力。你可以对不可并行化在算法上做一点改动,如果有可能,你也可以把一些移到可并行化放的部分。

优化串行分量

如果你优化一个程序的串行化部分,你也可以使用阿姆达尔定律来计算程序优化后的执行时间。如果不可并行部分通过一个因子O来优化,那么阿姆达尔定律看起来就像这样:

1
T(O, N) = B / O + (1 - B / O) / N

记住,现在程序的不可并行化部分占了B / O的时间,所以,可并行化部分就占了1 - B / O的时间。

如果B为0.1,O为2,N为5,计算看起来就像这样:

1
2
3
4
5
6
T(2,5) = 0.4 / 2 + (1 - 0.4 / 2) / 5
= 0.2 + (1 - 0.4 / 2) / 5
= 0.2 + (1 - 0.2) / 5
= 0.2 + 0.8 / 5
= 0.2 + 0.16
= 0.36

5、运行时间 vs. 加速

到目前为止,我们只用阿姆达尔定律计算了一个程序或算法在优化后或者并行化后的执行时间。我们也可以使用阿姆达尔定律计算加速比(speedup),也就是经过优化后或者串行化后的程序或算法比原来快了多少。

如果旧版本的程序或算法的执行时间为T,那么增速比就是:

1
Speedup = T / T(O , N);

为了计算执行时间,我们常常把T设为1,加速比为原来时间的一个分数。公式大致像下面这样:

1
Speedup = 1 / T(O,N)

如果我们使用阿姆达尔定律来代替T(O,N),我们可以得到下面的公式:

1
Speedup = 1 / ( B / O + (1 - B / O) / N)

如果B = 0.4, O = 2, N = 5, 计算变成下面这样:

1
2
3
4
5
6
7
Speedup = 1 / ( 0.4 / 2 + (1 - 0.4 / 2) / 5)
= 1 / ( 0.2 + (1 - 0.4 / 2) / 5)
= 1 / ( 0.2 + (1 - 0.2) / 5 )
= 1 / ( 0.2 + 0.8 / 5 )
= 1 / ( 0.2 + 0.16 )
= 1 / 0.36
= 2.77777 ...

上面的计算结果可以看出,如果你通过一个因子2来优化不可并行化部分,一个因子5来并行化可并行化部分,这个程序或算法的最新优化版本最多可以比原来的版本快2.77777倍。

6、测量,不要仅是计算

虽然阿姆达尔定律允许你并行化一个算法的理论加速比,但是不要过度依赖这样的计算。在实际场景中,当你优化或并行化一个算法时,可以有很多的因子可以被考虑进来。

内存的速度,CPU缓存,磁盘,网卡等可能都是一个限制因子。如果一个算法的最新版本是并行化的,但是导致了大量的CPU缓存浪费,你可能不会再使用x N个CPU来获得x N的期望加速。如果你的内存总线(memory bus),磁盘,网卡或者网络连接都处于高负载状态,也是一样的情况。

我们的建议是,使用阿姆达尔定律定律来指导我们优化程序,而不是用来测量优化带来的实际加速比。记住,有时候一个高度串行化的算法胜过一个并行化的算法,因为串行化版本不需要进行协调管理(上下文切换),而且一个单个的CPU在底层硬件工作(CPU管道、CPU缓存等)上的一致性可能更好。


8、并发的相关多线程设计模式

1、两阶段终止(Two Phase Termination)

在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会。

1、错误思路

  • 使用线程对象的 stop() 方法停止线程
    • stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁,可能会造成死锁问题
  • 使用 System.exit(int) 方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序都停止

2、正常做法——两阶段终止模式(interrupt实现)

1、实现流程图

img

2、实现方法

interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait,还是正常运行

  • 如果打断的是阻塞的线程,会清空打断状态,打断状态为false
  • 如果打断的是正常运行的线程,不会清空打断状态,打断状态为true
3、代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestTPT{
public static void main(String[] args) {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start;
try{
Thread.sleep(3500);
} catch(InterruptedException e) {
e.printStackTrace();
}
tpt.stop();
}
}


class TwoPhaseTermination{

private Thread monitorThread;

// 启动监控线程
public void start(){
monitorThread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 是否被打断
if(current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(2000); // 情况1:sleep被打断
log.debug("执行监控记录"); // 情况2:正常执行被打断
} catch (InterruptedException e) {
// 因为sleep出现异常后,会清除打断标记
// 需要重置打断标记
current.interrupt();
e.printStackTrace();
}
}
}, "监控线程" );
monitorThread.start();
}

// 停止监控线程
public void stop() {
monitorThread.interrupt();
}
}

执行结果:

1
2
3
4
5
6
7
8
9
11:49:42.915 c.TwoPhaseTermination [监控线程] - 执行监控记录 
11:49:43.919 c.TwoPhaseTermination [监控线程] - 执行监控记录
11:49:44.919 c.TwoPhaseTermination [监控线程] - 执行监控记录
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at cn.itcast.test.TwoPhaseTermination.lambda$start$0(Iest3. java:30)
at java.lang.Thread.run(Thread.java: 748)
11:49:45.413 c.TestTwoPhaseTermination [main] - stop
11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事

两个细节:

  1. 线程被打断时分为两种情况
    1. 情况1:线程在sleep时被打断,此时线程会抛出InterruptedException: sleep interrupted异常进入catch模块,不会清除打断标记,也就是说isInterrupted()返回false,所以需要在catch模块当中重置打断标记
    2. 情况2:线程在正常执行被打断,此时线程的打断标记不会被清除,即isInterrupted()返回true,在下一次的判断中进入if块执行break;语句退出死循环
  2. 线程使用的是isInterrupted()用来判断打断标记是否为true,即有没有被打断过。其实还有一个方法可以用来判断有没有被打断过,那就是interrupted()
    1. isInterrupted():判断当前线程是否被打断,不会清除==打断标记==
    2. interrupted():判断当前线程是否被打断,是一个静态方法,会清除==打断标记==

3、正常做法——两阶段终止模式(volatile实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class TestTPT{
public static void main(String[] args) {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start;
try{
Thread.sleep(3500);
log.debug("停止监控");
} catch(InterruptedException e) {
e.printStackTrace();
}
tpt.stop();
}
}


class TwoPhaseTermination{

// 监控线程
private Thread monitorThread;
// 打断标记
private volatile boolean stop = false;

// 启动监控线程
public void start(){
monitorThread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
// 是否被打断
if(stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(2000); // 情况1:sleep被打断
log.debug("执行监控记录"); // 情况2:正常执行被打断
} catch (InterruptedException e) {
}
}
}, "监控线程" );
monitorThread.start();
}

// 停止监控线程
public void stop() {
stop = true;
//这里依旧使用打断interrupt是为了即使监控线程在sleep当中也能马上结束,而不是等到sleep结束在停止
monitorThread.interrupt();
}
}

执行结果:

1
2
3
4
5
17:08:21.970 c.TwoPhaseTermination [监控线程] - 执行监控记录 
17:08:22.973 c.TwoPhaseTermination [监控线程] - 执行监控记录
17:08:23.974 c.TwoPhaseTermination [监控线程] - 执行监控记录
17:08:24.467 c.TwoPhaseTermination [mian] - 停止监控
17:08:24.467 c.TwoPhaseTermination [监控线程] - 料理后事

2、犹豫模式(Balking)——同步模式

1、定义

Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回

2、实现

以上面两阶段终止模式的例子,当调用了多次tpt.start;就会创建多个监控线程,其实这是错误的,监控线程只需要一个就够了,在第二次创建监控线程的时候应该直接返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.TwoPhaseTermination")
public class Test13 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
tpt.start();
tpt.start();

/*Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();*/
}
}

@Slf4j(topic = "c.TwoPhaseTermination")
class TwoPhaseTermination {
// 监控线程
private Thread monitorThread;
// 停止标记
private volatile boolean stop = false;
// 判断是否执行过 start 方法
private volatile boolean starting = false;

// 启动监控线程
public void start() {
synchronized (this) {
if (starting) { // false
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
starting = false;
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}

// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}

3、犹豫Balking模式还经常用来实现线程安全的单例

1
2
3
4
5
6
7
8
9
10
11
12
public final class Singleton {
private Singleton() {
}
private static Singleton INSTANCE = null;
public static synchronized Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}

对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。

3、保护性暂停(Guarded Suspension)——同步模式

1、定义

保护性暂停(Guarded Suspension)用在一个线程等待另一个线程的执行结果

要点:

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  • JDK 中,join 的实现、Future 的实现,采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

image-20210805201137709

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class GuardedObject {
// 结果
private Object response;

// 获取结果
public Object get(long timeout) {
synchronized (this) {
// 没有结果
while (response == null) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

3、应用

一个线程等待另一个线程的执行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
// 等待结果
log.debug("等待结果");
List<String> list = (List<String>) guardedobject.get();
log.debug("结果大小: {}" list.size());
},"t1").start();

new Thread(() -> {
log.debug("执行下载");
try {
// 子线程执行下载
List<String> list = Downloader.download();
guardedObject.complete(response);
} catch (IOException e) {
e.printStackTrace();
}
},"t2").start();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Downloader {
public static List<String> download() throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL("https://www.baidu.com/").openConnection();
List<String> lines = new ArrayList<>();
try (BufferedReader reader =
new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
lines.add(line);
}
}
return lines;
}
}

结果:

1
2
3
14:42:07.731 c.Test20 [t1] - 等待结果
14:42:07.731 c.Test20 [t2] - 执行下载
14:42:33.636 c.Test20 [t1] - 结果大小: 3

4、带超时版 GuardedObjec

如果要控制超时时间呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 增加超时效果
class GuardedObject {
// 结果
private Object response;

// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
log.debug("begin");
Object response = guardedobject.get(2000);
log.debug("结果大小: {}" response);
},"t1").start();

new Thread(() -> {
log.debug("begin");
// 睡眠1s
Sleeper.sleep(1);
guardedObject.complete(new Object());
},"t2").start();
}

如果线程t2睡眠1s,那么get没有超时,可以获得Object对象:

1
2
3
15:51:04.932 c.Test20 [Thread-1] - begin
15:51:04.932 c.Test20 [Thread-0] - begin
15:51:05.935 c.Test20 [Thread-0] - 结果是:java. lang .0bject@455b03c9

如果线程t2睡眠3s,那么get超时,不能获得Object对象:

1
2
3
15:52:07.993 c.Test20 [t2] - begin
15:52:07.993 c.Test20 [t1] - begin
15:52:09.997 c.Test20 [t1] - 结果是:null

测试虚假唤醒问题:把t2线程complete传入null(线程t2睡眠1s):(如果代码wait传入的是timeout而不是waitTime,这里的等待时间为3s(虚假唤醒1s,加设置的2s = 总共3s),而不是设置的2s)

1
2
3
15:52:11.975 c.Test20 [t2] - begin
15:52:11.975 c.Test20 [t1] - begin
15:52:13.979 c.Test20 [t1] - 结果是:null

5、扩展:多任务版 GuardedObject

代码:

图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员

如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。

注意:这里的结果等待者和结果产生者是一一对应的,所以采用的是保护性暂停模式,如果不是一一对应的话,使用的是生产者消费者模式

image-20210805215400468

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import cn.itcast.n2.util.Sleeper;
import lombok.extern.slf4j.Slf4j;

import java.util.Hashtable;
import java.util.Map;
import java.util.Set;

@Slf4j(topic = "c.Test20")
public class Test20 {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
// 生成3个居民
new People().start();
}
Sleeper.sleep(1);
for (Integer id : Mailboxes.getIds()) {
// 邮递员送信
new Postman(id, "内容" + id).start();
}
}
}

@Slf4j(topic = "c.People")
class People extends Thread{
@Override
public void run() {
// 收信
GuardedObject guardedObject = Mailboxes.createGuardedObject();
log.debug("开始收信 id:{}", guardedObject.getId());
Object mail = guardedObject.get(5000);
log.debug("收到信 id:{}, 内容:{}", guardedObject.getId(), mail);
}
}

@Slf4j(topic = "c.Postman")
class Postman extends Thread {
private int id;
private String mail;

public Postman(int id, String mail) {
this.id = id;
this.mail = mail;
}

@Override
public void run() {
GuardedObject guardedObject = Mailboxes.getGuardedObject(id);
log.debug("送信 id:{}, 内容:{}", id, mail);
guardedObject.complete(mail);
}
}

/**
* Mailboxes邮箱类是之间解耦类,并不与业务挂钩,代码可以复用
*/
class Mailboxes {
private static Map<Integer, GuardedObject> boxes = new Hashtable<>();

private static int id = 1;
// 产生唯一 id
// 加上synchronized保证生成id的线程安全
private static synchronized int generateId() {
return id++;
}

public static GuardedObject getGuardedObject(int id) {
// 注意这里使用的是remove方法,而不是get方法
// 因为这里的对应关系只需要存在一次,送完信就应该取消对应关系
// 如果没有取消对应关系的话,由于boxes是static类型,是不会进入垃圾回收的,
// 造成了内存泄漏,长期以往可能会导致OOM
return boxes.remove(id);
}

public static GuardedObject createGuardedObject() {
GuardedObject go = new GuardedObject(generateId());
boxes.put(go.getId(), go);
return go;
}

public static Set<Integer> getIds() {
return boxes.keySet();
}
}

// 增加超时效果
class GuardedObject {

// 标识 Guarded Object
private int id;

public GuardedObject(int id) {
this.id = id;
}

public int getId() {
return id;
}

// 结果
private Object response;

// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}

// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}

6、使用保护性暂停的好处

  1. 如果使用的是一个线程(A)使用join来等待另外一个线程(B)的结果的话,如果线程B给线程A结果,但是线程A还不能接收,线程B就不能往下运行,必须等待线程A接收结果之后才能往下运行。
  2. 如果使用的是保护性暂停模式的话,线程B在结束下载以后还能往下运行代码,没必要等待线程A接收结果
  3. 因为join是线程结束才返回,但是阻塞的线程只需要那个response有值,凭什么要去等另一个线程全部执行完
  4. 使用join的话,两线程交互的结果只能设置成全局的,而使用保护性暂停模式,可以把等待的结果设置成局部的(如示例当中的list)

4、生产者消费者模式(Producer Consumer)——异步模式

1、定义

要点:

  • 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK 中各种阻塞队列,采用的就是这种模式

image-20210805231147322

2、实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 消息类 不设置set方法,加上整个类被final修饰,保证没有其他方法去修改Message里面的值
final class Message {
// 消息对应的id,用来辨识message,用在查看消息是否发送成功等等
private int id;
// 消息的内容
private Object message;
public Message(int id, Object message) {
this.id = id;
this.message = message;
}

public int getId() {
return id;
}
public Object getMessage() {
return message;
}
}

// 消息队列 java线程间通信
@Slf4j(topic = "c.MessageQueue")
class MessageQueue {
// 消息队列集合
private LinkedList<Message> list;
// 队列容量
private int capacity;

public MessageQueue(int capacity) {
this.capacity = capacity;
list = new LinkedList<>();
}

// 获取消息
public Message take() {
synchronized (list) {
// 检查队列是否为空
while (list.isEmpty()) {
try {
log.debug("队列为空,消费者线程等待");
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
//从队列头部获取消息并返回
Message message = queue.removeFirst();
log.debug("已消费消息{}", message);
list.notifyAll();
return message;
}
}

// 存入消息
public void put(Message message) {
synchronized (list) {
// 检查队列是否已满
while (list.size() == capacity) {
try {
log.debug("队列已满,生产者线程等待");
list.wait();
} catch (InterruptedException e){
e.printStackTrace();
}
}
// 将消息加入队列尾部
list.addLast(message);
log.debug("已生产消息{}", message);
list.notifyAll();
}
}

3、应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
MessageQueue queue = new MessageQueue(2);
// 3 个生产者线程, 下载任务
for (int i = 0; i < 3; i++) {
// lambda表达式要求里面的变量为不可变的,不能直接写i,可以先将i赋值给id,在写入id
int id = i;
new Thread(() -> {
queue.put(new Message(id,"值"+id));
},"生产者" + i).start();
}

// 1个消费者线程,消费任务
new thread(() -> {
while(true) {
// 每隔1s消费一条消息
sleep(1);
Message message = queue.take();
}
},"消费者").start();
}

结果:

1
2
3
4
5
6
7
8
11:52:21.949 c.MessageQueue [生产者2] - 已生产消息Message{id=2, value=值2}
11:52:21.953 c.MessageQueue [生产者0] - 已生产消息Message{id=0, value=值0}
11:52:21.953 c.MessageQueue [生产者1] - 队列已满,生产者线程等待
11:52:22.948 c.MessageQueue [消费者] - 已消费消息Message{id=2, value=值2}
11:52:22.948 c.MessageQueue [生产者1] - 已生产消息Message{id=1, value=值1}
11:52:23.949 c.MessageQueue [消费者] - 已消费消息Message{id=0, value=值0}
11:52:24.949 c.MessageQueue [消费者] - 已消费消息Message{id=1, value=值1}
11:52:25.949 c.MessageQueue [消费者] - 队列为空,消费者线程等待

5、顺序控制(Sequence Control)——同步模式

1、固定运行顺序

比如,必须先 2 后 1 打印

1、wait notify 版

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 用来同步的对象
static final Object lock = new Object();
// t2 运行标记, 代表 t2 是否执行过
static boolean t2runed = false;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock) {
// 如果 t2 没有执行过
while (!t2runed) {
try {
// t1 先等一会
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("1");
}
},"t1").start();

Thread t2 = new Thread(() -> {
synchronized (lock) {
log.debug("1");
t2runed = true;
lock.notifyAll();
}
},"t2").start();
}

结果:

1
2
15:55:40.793 c.Test25[t2] - 2
15:55:40.796 c.Test25[t1] - 1

实际上使用ReentrantLock的await与signal方法与上面类似,这里不在展示。

2、Park Unpark 版

可以看到,实现上很麻烦:

  1. 首先,需要保证先 wait 再 notify,否则 wait 线程永远得不到唤醒。因此使用了『运行标记』来判断该不该 wait
  2. 第二,如果有些干扰线程错误地 notify 了 wait 线程,条件不满足时还要重新等待,使用了 while 循环来解决此问题(虚假唤醒问题)
  3. 最后,唤醒对象上的 wait 线程需要使用 notifyAll,因为『同步对象』上的等待线程可能不止一个

可以使用 LockSupport 类的 park 和 unpark 来简化上面的题目:代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.Test26")
public class Test26 {
public static void main(String[] args) {

Thread t1 = new Thread(() -> {
LockSupport.park();
log.debug("1");
}, "t1");
t1.start();

new Thread(() -> {
log.debug("2");
LockSupport.unpark(t1);
},"t2").start();
}
}

结果:

1
2
16:02:56.652 c.Test26[t2] - 2
16:02:56.655 c.Test26[t1] - 1

park 和 unpark 方法比较灵活,他俩谁先调用,谁后调用无所谓。并且是以线程为单位进行『暂停』和『恢复』,不需要『同步对象』和『运行标记』

2、交替输出

线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现(与线程间定制化通信有区别)

1、wait notify 版

需要借助等待标记来知道下一个唤醒的线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import lombok.extern.slf4j.Slf4j;

@Slf4j(topic = "c.Test27")
public class Test27 {
public static void main(String[] args) {
WaitNotify wn = new WaitNotify(1, 5);
new Thread(() -> {
wn.print("a", 1, 2);
}).start();
new Thread(() -> {
wn.print("b", 2, 3);
}).start();
new Thread(() -> {
wn.print("c", 3, 1);
}).start();
}
}

/*
输出内容 等待标记 下一个标记
a 1 2
b 2 3
c 3 1
*/
class WaitNotify {
// 打印 a 1 2
public void print(String str, int waitFlag, int nextFlag) {
for (int i = 0; i < loopNumber; i++) {
synchronized (this) {
while(flag != waitFlag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print(str);
flag = nextFlag;
this.notifyAll();
}
}
}

// 等待标记
private int flag; // 2
// 循环次数
private int loopNumber;

public WaitNotify(int flag, int loopNumber) {
this.flag = flag;
this.loopNumber = loopNumber;
}
}
2、Lock 条件变量版

Lock就不需要借助等待标记,但是需要主线程来启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import sun.rmi.runtime.Log;

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class Test30 {
public static void main(String[] args) throws InterruptedException {
AwaitSignal awaitSignal = new AwaitSignal(5);
Condition a = awaitSignal.newCondition();
Condition b = awaitSignal.newCondition();
Condition c = awaitSignal.newCondition();
new Thread(() -> {
awaitSignal.print("a", a, b);
}).start();
new Thread(() -> {
awaitSignal.print("b", b, c);
}).start();
new Thread(() -> {
awaitSignal.print("c", c, a);
}).start();

Thread.sleep(1000);
awaitSignal.lock();
try {
System.out.println("开始...");
a.signal();
} finally {
awaitSignal.unlock();
}

}
}

class AwaitSignal extends ReentrantLock{
private int loopNumber;

public AwaitSignal(int loopNumber) {
this.loopNumber = loopNumber;
}
// 参数1 打印内容, 参数2 进入哪一间休息室, 参数3 下一间休息室
public void print(String str, Condition current, Condition next) {
for (int i = 0; i < loopNumber; i++) {
lock();
try {
current.await();
System.out.print(str);
next.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
}
}
}
}

注意:该实现没有考虑 a,b,c 线程都就绪再开始

3、Park Unpark 版

依旧需要主线程来启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.locks.LockSupport;

@Slf4j(topic = "c.Test31")
public class Test31 {

static Thread t1;
static Thread t2;
static Thread t3;
public static void main(String[] args) {
ParkUnpark pu = new ParkUnpark(5);
t1 = new Thread(() -> {
pu.print("a", t2);
});
t2 = new Thread(() -> {
pu.print("b", t3);
});
t3 = new Thread(() -> {
pu.print("c", t1);
});
t1.start();
t2.start();
t3.start();

LockSupport.unpark(t1);
}
}

class ParkUnpark {
public void print(String str, Thread next) {
for (int i = 0; i < loopNumber; i++) {
LockSupport.park();
System.out.print(str);
LockSupport.unpark(next);
}
}

private int loopNumber;

public ParkUnpark(int loopNumber) {
this.loopNumber = loopNumber;
}
}

6、享元模式(Flyweight pattern)

1、简介

定义 英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时

比如说String,为了保证String不可变性,String在进行操作的时候经常使用的方法是:保护性拷贝。这种方式有个缺点:当拷贝的内容相当大的时候,这个时候对系统的性能以及内存的状态是非常不利的,这个时候就需要使用享元模式了

wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as possible with other similar objects

2、体现

1、包装类

在JDK中 BooleanByteShortIntegerLongCharacter 等包装类提供了 valueOf 方法,例如 Long 的valueOf **==会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象==**,大于这个范围,才会新建 Long 对象:

1
2
3
4
5
6
7
public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

LongCache的初始化:

1
2
3
4
5
6
7
8
9
10
private static class LongCache {
private LongCache(){}

static final Long cache[] = new Long[-(-128) + 127 + 1];

static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}

注意:

  • Byte, Short, Long 缓存的范围都是 -128~127
  • Character 缓存的范围是 0~127
  • Integer的默认范围是 -128~127
    • 最小值不能变
    • 但最大值可以通过调整虚拟机参数 -Djava.lang.Integer.IntegerCache.high 来改变
  • Boolean 缓存了 TRUE 和 FALSE
2、String 串池

在JVM的StringTable具体说明

3、BigDecimal BigInteger

注意:BigDecimal BigInteger的单个方法是线程安全的,但是方法之间组合组合不一定是线程安全的(有时候使用AutomicIntrger等等原子类来保证它们在组合下的线程安全)

3、DIY

例如:一个线上商城应用,QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。 这时预先创建好一批连接,放入连接池。一次请求到达后,从连接池获取连接,使用完毕后再还回连接池,这样既节约了连接的创建和关闭时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
import lombok.extern.slf4j.Slf4j;

import java.sql.*;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.concurrent.Executor;
import java.util.concurrent.atomic.AtomicIntegerArray;

public class Test3 {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}

@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;

// 2. 连接对象数组
private Connection[] connections;

// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;

// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}

// 5. 借连接
public Connection borrow() {
while(true) {
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果没有空闲连接,当前线程进入等待
synchronized (this) {
try {
log.debug("wait...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}

class MockConnection implements Connection {

private String name;

public MockConnection(String name) {
this.name = name;
}

@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}

@Override
public Statement createStatement() throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql) throws SQLException {
return null;
}

@Override
public String nativeSQL(String sql) throws SQLException {
return null;
}

@Override
public void setAutoCommit(boolean autoCommit) throws SQLException {

}

@Override
public boolean getAutoCommit() throws SQLException {
return false;
}

@Override
public void commit() throws SQLException {

}

@Override
public void rollback() throws SQLException {

}

@Override
public void close() throws SQLException {

}

@Override
public boolean isClosed() throws SQLException {
return false;
}

@Override
public DatabaseMetaData getMetaData() throws SQLException {
return null;
}

@Override
public void setReadOnly(boolean readOnly) throws SQLException {

}

@Override
public boolean isReadOnly() throws SQLException {
return false;
}

@Override
public void setCatalog(String catalog) throws SQLException {

}

@Override
public String getCatalog() throws SQLException {
return null;
}

@Override
public void setTransactionIsolation(int level) throws SQLException {

}

@Override
public int getTransactionIsolation() throws SQLException {
return 0;
}

@Override
public SQLWarning getWarnings() throws SQLException {
return null;
}

@Override
public void clearWarnings() throws SQLException {

}

@Override
public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {
return null;
}

@Override
public Map<String, Class<?>> getTypeMap() throws SQLException {
return null;
}

@Override
public void setTypeMap(Map<String, Class<?>> map) throws SQLException {

}

@Override
public void setHoldability(int holdability) throws SQLException {

}

@Override
public int getHoldability() throws SQLException {
return 0;
}

@Override
public Savepoint setSavepoint() throws SQLException {
return null;
}

@Override
public Savepoint setSavepoint(String name) throws SQLException {
return null;
}

@Override
public void rollback(Savepoint savepoint) throws SQLException {

}

@Override
public void releaseSavepoint(Savepoint savepoint) throws SQLException {

}

@Override
public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException {
return null;
}

@Override
public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {
return null;
}

@Override
public Clob createClob() throws SQLException {
return null;
}

@Override
public Blob createBlob() throws SQLException {
return null;
}

@Override
public NClob createNClob() throws SQLException {
return null;
}

@Override
public SQLXML createSQLXML() throws SQLException {
return null;
}

@Override
public boolean isValid(int timeout) throws SQLException {
return false;
}

@Override
public void setClientInfo(String name, String value) throws SQLClientInfoException {

}

@Override
public void setClientInfo(Properties properties) throws SQLClientInfoException {

}

@Override
public String getClientInfo(String name) throws SQLException {
return null;
}

@Override
public Properties getClientInfo() throws SQLException {
return null;
}

@Override
public Array createArrayOf(String typeName, Object[] elements) throws SQLException {
return null;
}

@Override
public Struct createStruct(String typeName, Object[] attributes) throws SQLException {
return null;
}

@Override
public void setSchema(String schema) throws SQLException {

}

@Override
public String getSchema() throws SQLException {
return null;
}

@Override
public void abort(Executor executor) throws SQLException {

}

@Override
public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException {

}

@Override
public int getNetworkTimeout() throws SQLException {
return 0;
}

@Override
public <T> T unwrap(Class<T> iface) throws SQLException {
return null;
}

@Override
public boolean isWrapperFor(Class<?> iface) throws SQLException {
return false;
}
}

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
15:26:48.211 c.Pool [THread-3] - wait...
15:26:48.211 c.Pool [Thread-0] - borrow MockConnection{name= '连接1'}
15:26:48.215 c.Pool [Thread-4] - wait...
15:26:48.215 c.Pool [Thread-2] - wait...
15:26:48.211 c.Pool [Thread-1] - borrow MockConnection{name= '连接2'}
15:26:48.397 c.Pool [Thread-0] - free MockConnection{name= '连接1'}
15:26:48.397 c.Pool [Thread-4] - wait...
15:26:48.397 c.Pool [Thread-2] - borrow MockConnection{name= '连接1'}
15:26:48.397 c.Pool [Thread-3] - wait...
15:26:48.412 c.Pool [Thread-1] - free MockConnection{name=' 连接2'}
15:26:48.412 c.Pool [Thread-3] - borrow MockConnection{name= '连接2'}
15:26:48.412 c.Pool [Thread-4] - wait...
15:26:48.796 c.Pool [Thread-3] - free MockConnection{name= '连接2'}
15:26:48.796 c.Pool [Thread-4] - borrow MockConnection{name= '连接2'}
15:26:49.340 c.Pool [Thread-2] - free MockConnection{name=' 连接1'}
15:26:49.561 c.Pool [Thread-4] - free MockConnection{name= '连接2'}

以上实现没有考虑:

  • 连接的动态增长与收缩
  • 连接保活(可用性检测)
  • 等待超时处理
  • 分布式 hash

对于关系型数据库,有比较成熟的连接池实现,例如c3p0, druid等 对于更通用的对象池,可以考虑使用apache commons pool(redis使用),例如redis连接池可以参考jedis中关于连接池的实现。

7、工作线程模式(Worker Thread)——异步模式

1、定义

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现就是线程池,也体现了经典设计模式中的享元模式

例如,海底捞的服务员(线程),轮流处理每位客人的点餐(任务),如果为每位客人都配一名专属的服务员,那么成本就太高了(对比另一种多线程设计模式:Thread-Per-Message

注意:不同任务类型应该使用不同的线程池,这样能够避免饥饿,并能提升效率

例如,如果一个餐馆的工人既要招呼客人(任务类型A),又要到后厨做菜(任务类型B)显然效率不咋地,分成服务员(线程池A)与厨师(线程池B)更为合理,当然你能想到更细致的分工

2、饥饿

固定大小线程池会有饥饿现象:

  • 两个工人是同一个线程池中的两个线程
  • 他们要做的事情是:为客人点餐和到后厨做菜,这是两个阶段的工作
    • 客人点餐:必须先点完餐,等菜做好,上菜,在此期间处理点餐的工人必须等待
    • 后厨做菜:没啥说的,做就是了
  • 比如工人A 处理了点餐任务,接下来它要等着 工人B 把菜做好,然后上菜,他俩也配合的蛮好
  • 但现在同时来了两个客人,这个时候工人A 和工人B 都去处理点餐了,这时没人做饭了,饥饿
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j(topic = "c.TestDeadLock")
public class TestStarvation {

static final List<String> MENU = Arrays.asList("地三鲜", "宫保鸡丁", "辣子鸡丁", "烤鸡翅");
static Random RANDOM = new Random();
static String cooking() {
return MENU.get(RANDOM.nextInt(MENU.size()));
}
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);

pool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
pool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

}
}

输出:

1
2
15:28:41.386 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:28:41.386 c.TestDeadLock [pool-1-thread-2] - 处理点餐...

解决方法:可以增加线程池的大小,不过不是根本解决方案,还是前面提到的,不同的任务类型,采用不同的线程池,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

@Slf4j(topic = "c.TestDeadLock")
public class TestStarvation {

static final List<String> MENU = Arrays.asList("地三鲜", "宫保鸡丁", "辣子鸡丁", "烤鸡翅");
static Random RANDOM = new Random();
static String cooking() {
return MENU.get(RANDOM.nextInt(MENU.size()));
}
public static void main(String[] args) {
ExecutorService waiterPool = Executors.newFixedThreadPool(1);
ExecutorService cookPool = Executors.newFixedThreadPool(1);

waiterPool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});
waiterPool.execute(() -> {
log.debug("处理点餐...");
Future<String> f = cookPool.submit(() -> {
log.debug("做菜");
return cooking();
});
try {
log.debug("上菜: {}", f.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

}
}

输出:

1
2
3
4
5
6
15:33:14.925 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:33:14.928 c.TestDeadLock [pool-2-thread-1] - 做菜
15:33:14.929 c.TestDeadLock [pool-1-thread-1] - 上菜: 辣子鸡丁
15:33:14.931 c.TestDeadLock [pool-1-thread-1] - 处理点餐...
15:33:14.931 c.TestDeadLock [pool-2-thread-1] - 做菜
15:33:14.931 c.TestDeadLock [pool-1-thread-1] - 上菜: 宫保鸡丁

3、创建多少线程池合适

  • 过小会导致程序不能充分地利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存
1、CPU 密集型运算

通常采用 cpu 核数 + 1 能够实现最优的 CPU 利用率,**+1 是保证当线程由于页缺失故障(操作系统)或其它原因导致暂停时,额外的这个线程就能顶上去,保证 CPU 时钟周期不被浪费**

2、I/O 密集型运算

CPU 不总是处于繁忙状态,例如,当你执行业务计算时,这时候会使用 CPU 资源,但当你执行 I/O 操作时、远程RPC 调用时,包括进行数据库操作时,这时候 CPU 就闲下来了,你可以利用多线程提高它的利用率。

经验公式:线程数 = 核数 * 期望 CPU 利用率 * 总时间(CPU计算时间+等待时间) / CPU 计算时间

例如 4 核 CPU 计算时间是 50% ,其它等待时间是 50%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 50% = 8

例如 4 核 CPU 计算时间是 10% ,其它等待时间是 90%,期望 cpu 被 100% 利用,套用公式:4 * 100% * 100% / 10% = 40

4、自定义线程池

具体参考7、JUC当中的15、ThreadPool线程池的9、自定义线程池

5、不可变(Immutability)模式

如果对象一旦被创建,状态就不会再发生任何变化,并且只允许存在只读方法,这个对象就是不可变对象。利用不可变对象解决并发问题的模式,就是不可变模式。快速实现具备不可变性的类时,将类设置成final,类内的所有属性设置成final,只暴露只读方法即可。

经常用到的String对象和各种基础类型的包装类,比如,Long,Integer都具备不可变性。更进一步,基本数据类型的包装类都用到了享元模式(Flyweight Pattern),即在JVM启动时,创建一个对象池,当创建包装类型的对象时,首先查找对象池是否存在,如果不存在,才会创建新对象,并将其放入对象池中。比如,Long对象就默认缓存了[-128,127]之间的对象。几乎所有用到了享元模式的对象,比如,包装类对象,都不适合做锁,因为看上去是私有的这些对象,其实是共用的,会导致并发问题。

但是在使用不可变模式时,一定要搞清楚特定不可变对象的边界在哪里。比如,一个final类C的final成员变量a,当a的内部存在非final的其他对象时,并且C中存在着get_a的public接口,那么C就不是线程安全的。

6、Copy-on-Write模式

Copy-on-Write模式适用于对数据的实时性不敏感,读多写少且对读性能要求极为苛刻的小数据场景。

具体的实现也很简单,当数据需要修改时,先复制一份出来,在复制的数据上进行修改,并发读还是在旧的数据上,当数据修改完成后,再将老数据替换为修改后的新数据即可。但需要注意的是,当发生并发写时,可以使用CAS的策略来完成。

7、线程本地存储

Java语言提供ThreadLocal实现避免共享,即每个线程拥有自己的一份数据,线程之间没有竞争关系。

它的具体实现原理有点反直觉,因为ThreadLocal本质上仅仅是一个代理工具类,真正的数据存储在Thread类中。即,当ThreadLocal.get()获取线程本地数据时,通过Thread.currentThread().threadLocals来获取线程内真正的本地对象进行操作。

这种设计方式,从业务上看,线程的本地数据存在线程内部显然更合理,更重要的是,这样做不容易产生内存泄漏,因为线程本地对象和线程同生命周期,当线程被gc时,其数据也同样可以被gc掉。

但需要注意的是,在线程池的场景中,因为线程池中的线程通常与进程是同生共死的,即使线程本地变量的生命周期已经结束了,但因为该线程池尚未被释放,数据也是无法被回收的。因此,在这种场景下,ThreadLocal方案要小心使用。

8、Thread-Per-Message

现实世界中,很多事情需要委托他人办理,同样的场景,在并发编程领域,就是Thread-Per_message模式,简而言之,就是由一个线程接收任务,并发的为每一个收到的任务分配一个独立线程,这是最简单的分工方法,实现起来也非常简单。

线程在Java中是成本非常高的对象,本质上并不适合高并发场景。但是,换个角度思考,语言,工具和框架本身应该是帮助我们更敏捷的实现稳定可靠的方案,Thread-Per-Message是一种最简单的分工方法,Java语言支持不了,显然是Java语言本身的问题。

在Go语言中,存在一种轻量级线程,即协程的方案。在协程的架构下,Thread-Per-Message模式就完全没有问题了。


参考文档

Juc_并发编程目录

透彻理解Java并发编程系列

Java 全栈知识体系

Java 内存模型详解

面试官:说一下公平锁和非公平锁的区别?

ReentrantLock中的公平锁可能并不是真正意义上的公平

可重入锁

死锁面试题(什么是死锁,产生死锁的原因及必要条件)

乐观锁、悲观锁

什么是乐观锁,什么是悲观锁

本文主要参考自泰迪的bagwell的https://www.jianshu.com/p/32a15ef2f1bf和https://www.jianshu.com/p/6a14d0b54b8d,在此基础上参考了如下文章

推荐阅读ForkJoinPool的作者Doug Lea的一篇文章《A Java Fork/Join Framework》英文原文地址

JUC

Java并发编程实战(三:并发设计模式)

黑马程序员全面深入学习Java并发编程,JUC并发编程全套教程

【尚硅谷】大厂必备技术之JUC并发编程2021最新版

一、五大算法

0、穷举法

穷举法简单粗暴,没有什么问题是搞不定的,只要你肯花时间。同时对于小数据量,穷举法就是最优秀的算法。

1、贪婪算法

贪婪算法可以获取到问题的局部最优解,不一定能获取到全局最优解,同时获取最优解的好坏要看贪婪策略的选择。特点就是简单,能获取到局部最优解。同样是贪婪算法,不同的贪婪策略会导致得到差异非常大的结果。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/51417211

2、动态规划算法

当最优化问题具有重复子问题和最优子结构的时候,就是动态规划出场的时候了。动态规划算法的核心就是提供了一个memory来缓存重复子问题的结果,避免了递归的过程中的大量的重复计算。动态规划算法的难点在于怎么将问题转化为能够利用动态规划算法来解决。当重复子问题的数目比较小时,动态规划的效果也会很差。如果问题存在大量的重复子问题的话,那么动态规划对于效率的提高是非常恐怖的。

具体的详细解析请参见下面的文章:

3、分治算法(divide and conquer)

分治算法的逻辑更简单了,就是一个词,分而治之。分治算法就是把一个大的问题分为若干个子问题,然后在子问题继续向下分,一直到base cases,通过base cases的解决,一步步向上,最终解决最初的大问题。分治算法是递归的典型应用。

1、基本概念

正如名字divide and conquer所言,分治算法分为两步,一步是divide,一步是conquer。

Divide:Smaller Problems are solved recursively except base cases.

Conquer:The solution to the original problem is then formed from the solutions to the sub-problem.

在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……

任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,……。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。

2、基本思想及策略

分治法的设计思想是:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之

分治策略是:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。

如果原问题可分割成k个子问题,1<k≤n,且这些子问题都可解并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。

3、分治法适用的情况

分治法所能解决的问题一般具有以下几个特征:

  1. 该问题的规模缩小到一定的程度就可以容易地解决
  2. 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质
  3. 利用该问题分解出的子问题的解可以合并为该问题的解;
  4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题

第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;

第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;

第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法动态规划法

第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好

4、分治法的基本步骤

分治法在每一层递归上都有三个步骤:

  1. step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
  2. step2 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
  3. step3 合并:将各个子问题的解合并为原问题的解。

它的一般的算法设计模式如下: Divide-and-Conquer(P)

  1. if |P|≤n0

  2. then return(ADHOC(P))

  3. 将P分解为较小的子问题 P1 ,P2 ,…,Pk

  4. for i ← 1 to k

  5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi

  6. T ← MERGE(y1,y2,…,yk) △ 合并子问题

  7. return(T)

其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,…,Pk的相应的解y1,y2,…,yk合并为P的解。

5、分治排序的运行时间问题及复杂度分析

img

一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有: T(n) = k*T(n/m)+f(n)

通过迭代法求得方程的解:

递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而当:mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。

6、可使用分治法求解的一些经典问题

  1. 二分搜索
  2. 大整数乘法
  3. Strassen矩阵乘法
  4. 棋盘覆盖
  5. 合并排序
  6. 快速排序
  7. 线性时间选择
  8. 最接近点对问题
  9. 循环赛日程表
  10. 汉诺塔

7、依据分治法设计程序时的思维过程

实际上就是类似于数学归纳法,找到解决本问题的求解方程公式,然后根据方程公式设计递归程序。

  1. 一定是先找到最小问题规模时的求解方法
  2. 然后考虑随着问题规模增大时的求解方法
  3. 找到求解的递归函数式后(各种规模或因子),设计递归程序即可。

8、具体问题分析(java)

9、总结

分治算法的一个核心在于子问题的规模大小是否接近,如果接近则算法效率较高。

分治算法和动态规划都是解决子问题,然后对解进行合并;但是分治算法是寻找远小于原问题的子问题(因为对于计算机来说计算小数据的问题还是很快的),同时分治算法的效率并不一定好,而动态规划的效率取决于子问题的个数的多少,子问题的个数远小于子问题的总数的情况下(也就是重复子问题多),算法才会很高效。

10、具体的详细解析请参见下面的文章

4、回溯算法

回溯算法是深度优先策略的典型应用,回溯算法就是沿着一条路向下走,如果此路不同了,则回溯到上一个分岔路,在选一条路走,一直这样递归下去,直到遍历万所有的路径。八皇后问题是回溯算法的一个经典问题,还有一个经典的应用场景就是迷宫问题。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/17354461

5、 分支限界算法

回溯算法是深度优先,那么分支限界法就是广度优先的一个经典的例子。回溯法一般来说是遍历整个解空间,获取问题的所有解,而分支限界法则是获取一个解(一般来说要获取最优解)。

具体的详细解析请参见下面的文章:http://blog.csdn.net/changyuanchn/article/details/17102037

二、排序

三、查找

1、Json的几个注解

pom.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.9.0</version>
</dependency>

@JsonFormat与@Date TimeFormat注解的使用

@JsonFormat

作用:从数据库获取时间传到前端进行展示的时候,我们有时候可能无法得到一个满意的时间格式的时间日期,在数据库中显示的是正确的时间格式,获取出来却变成了很丑的时间戳。@JsonFormat注解很好的解决了这个问题。

使用:

1
2
3
//设置时区为上海时区,时间格式自己据需求定。
@JsonFormat(pattern="yyyy-MM-dd",timezone = "GMT+8")
private Date testTime;

这里解释一下:@JsonFormat(pattern=”yyyy-MM-dd”,timezone = “GMT+8”)

  • pattern:是你需要转换的时间日期的格式
  • timezone:是时间设置为东八区,避免时间在转换中有误差

提示:@JsonFormat注解可以在属性的上方,同样可以在属性对应的get方法上,两种方式没有区别

完成上面两步之后,我们用对应的实体类来接收数据库查询出来的结果时就完成了时间格式的转换,再返回给前端时就是一个符合我们设置的时间格式了

@Date TimeFormat

pom.xml:

1
2
3
4
5
6
<!-- joda-time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.3</version>
</dependency>

作用:我们在使用WEB服务的时,可能会需要用到,传入时间给后台,比如注册新用户需要填入出生日期等,这个时候前台传递给后台的时间格式同样是不一致的,@DataTimeFormat便很好的解决了这个问题。

使用:在controller层我们使用spring mvc 表单自动封装映射对象时,我们在对应的接收前台数据的对象的属性上加@DateTimeFormat

1
2
3
@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone="GMT+8")
private Date symstarttime;

我这里就只贴这两个属性了,这里我两个注解都同时使用了,因为我既需要取数据到前台,也需要前台数据传到后台,都需要进行时间格式的转换,可以同时使用。

总结:

  • 注解@JsonFormat主要是后台到前台的时间格式的转换
  • 注解@DataFormAT主要是前后到后台的时间格式的转换

资料来源:@JsonFormat与@DateTimeFormat注解的使用

@JsonProperty使用详解

作用:@JsonProperty注解主要用于实体类的属性上,作用可以简单的理解为在反序列化的时候给属性重命名(多一个名字来识别)

使用:

1
2
@JsonProperty(value = "fake_name", required = true)
private String fakeName;

注意:

  • 使用JSON.toJsonString的时候实体类需要有getter方法,否则会输出{}
  • @requestBody注解需要在post请求下才能正常使用.

资料来源:@JsonProperty使用详解

@JsonInclude

作用:

  • JsonJsonInclude.Include.ALWAYS 这个是默认策略,任何情况下都序列化该字段,和不写这个注解是一样的效果。
  • JsonJsonInclude.Include.NON_NULL这个最常用,即如果加该注解的字段为null,那么就不序列化这个字段了。
  • JsonJsonInclude.Include.NON_ABSENT这个包含NON_NULL,即为null的时候不序列化。

使用:

1
2
@JsonInclude(JsonInclude.Include.NON_NULL)
private String username;

资料来源:jackSon中@JsonInclude注解详解

@JsonIgnore注解

作用:在json序列化时将pojo中的一些属性忽略掉,标记在属性或者方法上,返回的json数据即不包含该属性。

使用:

1
2
@JsonIgnore
private String password;// 密码

资料来源:@JsonIgnore注解

2、ip2region——Java 根据 IP 地址来获取位置

pom.xml:

1
2
3
4
5
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>ip2region</artifactId>
<version>1.7.2</version>
</dependency>

然后下载 IP库 ip2region.db: https://gitee.com/lionsoul/ip2region/tree/master/data

下载解压后只需要 data 目录下的 ip2region.db 就可以了 .

把 ip2region.db 复制到 maven 项目的 resources 目录下.

然后具体实现,可以把以下代码封装成方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Ip2RegionTest {
public static void main(String[] args){
//ip
String ip="220.248.12.158";

// 判断是否为IP地址 (可用)
//boolean isIpAddress = Util.isIpAddress(ip);

//ip和long互转 (可用)
//long ipLong = Util.ip2long(ip);
//String strIp = Util.long2ip(ipLong);

//根据ip进行位置信息搜索
DbConfig config = new DbConfig();

//获取ip库的位置(放在src下)(直接通过测试类获取文件Ip2RegionTest为测试类)
String dbfile = Ip2RegionTest.class.getResource("/ip2region.db").getPath();

DbSearcher searcher = new DbSearcher(config, dbfile);

//采用Btree搜索
DataBlock block = searcher.btreeSearch(ip);

//打印位置信息(格式:国家|大区|省份|城市|运营商)
System.out.println(block.getRegion());
}
}

还有一种实现方法如下:

此内容参考了 ip2region源码的 : org.lionsoul.ip2region.test.TestSearcher.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.test;

import java.io.File;
import java.lang.reflect.Method;

import org.lionsoul.ip2region.DataBlock;
import org.lionsoul.ip2region.DbConfig;
import org.lionsoul.ip2region.DbSearcher;
import org.lionsoul.ip2region.Util;

public class IPUtil {

public static String getCityInfo(String ip){

//db
String dbPath = IPUtil.class.getResource("/ip2region.db").getPath();

File file = new File(dbPath);
if ( file.exists() == false ) {
System.out.println("Error: Invalid ip2region.db file");
}

//查询算法
int algorithm = DbSearcher.BTREE_ALGORITHM; //B-tree
//DbSearcher.BINARY_ALGORITHM //Binary
//DbSearcher.MEMORY_ALGORITYM //Memory
try {
DbConfig config = new DbConfig();
DbSearcher searcher = new DbSearcher(config, dbPath);

//define the method
Method method = null;
switch ( algorithm )
{
case DbSearcher.BTREE_ALGORITHM:
method = searcher.getClass().getMethod("btreeSearch", String.class);
break;
case DbSearcher.BINARY_ALGORITHM:
method = searcher.getClass().getMethod("binarySearch", String.class);
break;
case DbSearcher.MEMORY_ALGORITYM:
method = searcher.getClass().getMethod("memorySearch", String.class);
break;
}

DataBlock dataBlock = null;
if ( Util.isIpAddress(ip) == false ) {
System.out.println("Error: Invalid ip address");
}

dataBlock = (DataBlock) method.invoke(searcher, ip);

return dataBlock.getRegion();

} catch (Exception e) {
e.printStackTrace();
}

return null;
}


public static void main(String[] args) throws Exception{
System.err.println(getCityInfo("220.248.12.158"));
}
}

资料来源:Java 根据 IP 地址来获取 位置 – 使用 ip2region

Joda-Time——Java 日期时间处理库

pom.xml

1
2
3
4
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
</dependency>

具体API查看:https://www.oschina.net/p/joda-time?hmsr=aladdin1e1

[TOC]

第一章 操作系统引论及概述


在这里插入图片描述

1.1.1、概念、功能与目标

  1. 定义:

    操作系统(Operating System,OS)是指控制和管理整个计算机系统的硬件软件资源,并合理地组织调度计算机的工作和资源的分配;以提供给用户和其他软件方便的接口和环境;它是计算机系统中最基本的系统软件

    1. 操作系统是系统资源的管理者,负责管理协调硬件、软件等计算机资源的工作
    2. 向上层的应用程序、用户提供方便易用的服务
    3. 操作系统是最接近硬件的一层软件,是系统软件不是硬件

    image-20210406102804945

  2. 功能与目标

    1. 操作系统是系统资源的管理者

      image-20210406102859419

    2. 向上层提供方便易用的服务

      • 命令接口

        • 联机命令接口实例(Windows系统) 联机命令接口=交互式命令接口

          特点:用户说一句,系统跟着做一句

        • 脱机命令接口实例(Windows系统) 脱机命令接口=批处理命令接口

          使用windows系统的搜索功能,搜索C盘中的 *.bat文件,用记事本任意打开一个。

          特点:用户说一堆,系统跟着做一堆

      • 程序接口

        可以在程序中进行系统调用来使用程序接口。普通用户不能直接使用程序接口,只能通过程序代码间接使用。

        如C盘Windows\System32中有很多的*.dll文件。程序员在程序中调用(该调用过程即为系统调用)即可实现创建窗口等功能。

        image-20210406104140771

      • GUI:图形用户界面(Graphical User Interface)

        用户可以使用形象的图形界面进行操作,而不再需要记忆复杂的命令、参数。
        例子:在Windows 操作系统中,删除一个文件只需要把文件“拖拽”到回收站即可。

      image-20210406104309877

    3. 操作系统是最接近硬件的一层软件

      封装思想:操作系统把一些丑陋的硬件功能封装成简单易用的服务,使用户能更方便地使用计算机,用户无需关心底层硬件的原理,只需要对操作系统发出命令即可

      image-20210406104343359

  3. 脑图

    image-20210406104426461

1.1.2、操作系统的四个特征

  1. 并发

    并发与并行的区别:

    • 并发:两个或多个事件在同一时间间隔内发生。这些事件宏观上是同时发生的,但微观上是交替发生的。
    • 并行:指两个或多个事件在同一时刻同时发生

    例子:

    image-20210406111514894

    操作系统的并发性指计算机系统中“同时”运行着多个程序,这些程序宏观上看是同时运行着的,而微观上看是交替运行的。

    操作系统就是伴随着“多道程序技术”而出现的。因此,操作系统和程序并发是一起诞生的

    注意:

    • 单核CPU同一时刻只能执行一个程序,各个程序只能并发地执行
    • 多核CPU同一时刻可以同时执行多个程序,多个程序可以并行地执行
  2. 共享

    共享即资源共享,是指系统中的资源可供内存中多个并发执行的进程共同使用。

    • 互斥共享方式:

      系统中的某些资源,虽然可以提供给多个进程使用,但一个时间段内只允许一个进程访问该资源

    • 同时共享方式:

      系统中的某些资源,允许一个时间段内由多个进程“同时”对它们进行访问

    所谓的“同时”往往是宏观上的,而在微观上,这些进程可能是交替地对该资源进行访问的(即分时共享

    生活实例:

    • 互斥共享方式:使用QQ和微信视频。同一时间段内摄像头只能分配给其中一个进程。
    • 同时共享方式:使用QQ发送文件A,同时使用微信发送文件B。宏观上看,两边都在同时读取并发送文件,说明两个进程都在访问硬盘资源,从中读取数据。微观上看,两个进程是交替着访问硬盘的。

    并发与共享是操作系统最基本的两个特征,两者互为存在条件

    • 并发性指计算机系统中同时存在着多个运行着的程序。
    • 共享性是指系统中的资源可供内存中多个并发执行的进程共同使用。

    image-20210406112122162

  3. 虚拟

    虚拟是指把一个物理上的实体变为若干个逻辑上的对应物。物理实体(前者)是实际存在的,而逻辑上对应物(后者)是用户感受到的。

    虚拟技术

    • 空分复用技术(如虚拟存储器技术):实际只有4GB的内存,在用户看来似乎远远大于4GB
    • 时分复用技术(如虚拟处理器):微观上处理机在各个微小的时间段内交替着为各个进程服务

    显然,如果失去了并发性,则一个时间段内系统中只需运行一道程序,那么就失去了实现虚拟性的意义了。因此,没有并发性,就谈不上虚拟性

  4. 异步

    异步是指,在多道程序环境下,允许多个程序并发执行,但由于资源有限,进程的执行不是一贯到底的,而是走走停停,以不可预知的速度向前推进,这就是进程的异步性。

    由于并发运行的程序会争抢着使用系统资源,而系统中的资源有限,因此进程的执行不是一贯到底的,而是走走停停的,以不可预知的速度向前推进。

    如果失去了并发性,即系统只能串行地运行各个程序,那么每个程序的执行会一贯到底。只有系统拥有并发性,才有可能导致异步性。

脑图:

image-20210406112516655

1.1.3、操作系统的发展与分类

操作系统的发展:

  1. 手工操作阶段

    主要缺点:用户独占全机、人机速度矛盾导致资源利用率极低

  2. 批处理阶段

    1. 单道批处理系统

      引入脱机输入/输出技术(用外围机+磁带完成),并由**监督程序(操作系统的雏形)**负责控制作业的输入、输出

      主要优点:缓解了一定程度的人机速度矛盾,资源利用率有所提升。

      主要缺点:内存中仅能有一道程序运行,只有该程序运行结束之后才能调入下一道程序。CPU有大量的时间是在空闲等待I/O完成。资源利用率依然很低。

    2. 多道批处理系统

      操作系统正式诞生,用于支持多道程序并发运行

      主要优点:多道程序并发执行,共享计算机资源。资源利用率大幅提升,CPU和其他资源更能保持“忙碌”状态,系统吞吐量增大

      主要缺点:用户响应时间长,没有人机交互功能(用户提交自己的作业之后就只能等待计算机处理完成,中间不能控制自己的作业执行。eg:无法调试程序/无法在程序运行过程中输入一些参数)

  3. 分时操作系统

    计算机以时间片为单位轮流为各个用户/作业服务,各个用户可通过终端与计算机进行交互。

    主要优点:用户请求可以被即时响应,解决了人机交互问题。允许多个用户同时使用一台计算机,并且用户对计算机的操作相互独立,感受不到别人的存在。

    主要缺点:不能优先处理一些紧急任务。操作系统对各个用户/作业都是完全公平的,循环地为每个用户/ 作业服务一个时间片,不区分任务的紧急性

  4. 实时操作系统

    在实时操作系统的控制下,计算机系统接收到外部信号后及时进行处理,并且要在严格的时限内处理完事件。实时操作系统的主要特点是及时性可靠性

    • 硬实时系统:必须在绝对严格的规定时间内完成处理。如:导弹控制系统、自动驾驶系统
    • 软实时系统:能接受偶尔违反时间规定。如:12306火车订票系统

    主要优点:能够优先响应一些紧急任务,某些紧急任务不需时间片排队。

操作系统的分类:

  1. 网络操作系统

    伴随着计算机网络的发展而诞生的,能把网络中各个计算机有机地结合起来,实现数据传送等功能,实现网络中各种资源的共享(如文件共享)和各台计算机之间的通信。(如:Windows NT 就是一种典型的网络操作系统,网站服务器就可以使用)

  2. 分布式操作系统

    主要特点是分布性并行性。系统中的各台计算机地位相同,任何工作都可以分布在这些计算机上,由它们并行、协同完成这个任务

  3. 个人计算机操作系统

    如 Windows XP、MacOS,方便个人使用

脑图:

image-20210406115156103

1.1.4、操作系统的运行机制与体系结构

  1. 运行机制

    • 两种指令

      • 特权指令:不允许用户程序使用。如内存清零指令
      • 非特权指令:如普通的运算指令
    • 两种处理器状态

      • 用户态(目态):此时CPU只能执行非特权指令
      • 核心态(管态):特权指令、非特权指令都能执行

      两种处理器状态用程序状态字寄存器(PSW)中的某标志位来标识当前处理器处于什么状态,如0表示用户态,1表示核心态。

    • 两种程序

      • 内核程序

        操作系统的内核程序是系统的管理者,既可以执行特权指令,也可以执行非特权指令,运行在核心态

      • 应用程序

        为了保证系统的安全运行,普通应用程序只能执行非特权指令,运行在用户态

    image-20210406125651838

  2. 操作系统内核

    内核是计算机上配置的底层软件,是操作系统最基本、最核心的部分

    实现操作系统内核功能的那些程序就是内核程序

    • 时钟管理:实现计时管理
    • 中断处理:负责实现中断机制
    • 原语
      • 是一种特殊的程序
      • 处于操作系统最底层,是最接近硬件的部分
      • 这种程序的运行具有原子性,其运行只能一气呵成,不可中断
      • 运行时间短,调用频繁
    • 对系统资源进行管理的功能(有的操作系统不把这部分功能归为“内核功能”。也就是说,不同的操作系统,对内核功能的划分可能并不一样)
      • 进程管理
      • 存储器管理
      • 设备管理
  3. 体系结构

    • 大内核

      将操作系统的主要功能都作为系统内核运行在核心态

      优点:高性能

      缺点:内核代码大,结构混乱,难以维护

      典型的大内核/宏内核/单内核操作系统:Linux、UNIX

    • 微内核

      只把最基本的功能保留在内核

      优点:内核功能少,结构清晰,方便维护

      缺点:需要频繁地在核心态与用户态之间切换,性能低

      典型的微内核操作系统:Windows NT

    image-20210408192745129

    类比:

    image-20210406131034245

    image-20210406130802115

脑图:

image-20210406115337381

1.1.5、中断与异常

  1. 中断机制的诞生

    在早期的计算机没有中断机制,各个程序只能串行执行,系统资源的利用率低。

    为了解决上述问题,人们发明了操作系统(作为计算机的管理者),引入中断机制,实现了多道程序并发执行。

    本质:发生中断就意味着需要操作系统介入,开展管理工作

  2. 中断的概念与作用

    • 中断发生时,CPU立即进入核心态
    • 当中断发生后,当前运行的进程暂停运行,并有操作系统内核对中断进行处理
    • 对于不同的中断信号,会进行不同的处理

    发生中断就意味着需要操作系统介入,开展管理工作。由于操作系统的管理工作(比如进程切换、分配I/O设备等)需要使用特权指令,因此CPU要从用户态转为核心态。中断可以使CPU从用户态切换为核心态,使操作系统获得计算机的控制权。有了中断,才能实现多道程序并发执行。

    中断是实现CPU从用户态切换到核心态的唯一途径。通过执行一个特权指令,将程序状态字(PSW)对标志位设置为“核心态”。

  3. 中断(广义的中断)的分类

    • 内中断(也称“异常、例外、陷入”):与当前执行的指令有关,中断信号来源于CPU内部
      • 自愿中断:指令中断(如:系统调用时使用的访管指令(又叫陷入指令、trap指令))
      • 强迫中断
        • 硬件故障(如:缺页)
        • 软件故障(如:整数除0)
    • 外中断(也称“中断(狭义的中断)”):与当前执行的指令无关,中断信号来源于CPU外部
      • 外设请求(如:I/O操作完成发出的中断信号)
      • 人工干预(如:用户强行终止一个进程)

    image-20210406134755249

    另一种分类方式:

    • 内中断(也称“异常、例外、陷入”):与当前执行的指令有关,中断信号来源于CPU内部
      • 陷阱、陷入(trap):有意而为之的异常,如系统调用
      • 故障(fault):由错误条件引起的,可能被内核程序修复。内核程序修复故障后会把CPU使用权还给应用程序,让它继续执行下去。如:缺页故障。
      • 终止(abort):由致命错误引起,内核程序无法修复该错误,因此一般不再将CPU使用权还给引发终止的应用程序,而是直接终止该应用程序。如:整数除0、非法使用特权指令。
    • 外中断(也称“中断(狭义的中断)”):与当前执行的指令无关,中断信号来源于CPU外部
      • 外设请求(如:I/O操作完成发出的中断信号)
      • 人工干预(如:用户强行终止一个进程)

    image-20210406135217118

  4. 外中断的处理过程

    1. 检查:执行完每个指令之后,CPU都要检查当前是否有外部中断信号
    2. 保护:如果检测到外部中断信号,则需要保护被中断进程的CPU环境(如程序状态字PSW、程序计数器PC、各种通用寄存器)
    3. 处理:根据中断信号类型转入相应的中断处理程序
    4. 恢复:恢复原进程的CPU环境并退出中断,返回原进程继续往下执行
  5. 中断机制的基本原理

    不同的中断信号,需要用不同的中断处理程序来处理。当CPU检测到中断信号后,会根据中断信号的类型去查询“中断向量表”,以此来找到相应的中断处理程序在内存中的存放位置。

    显然,中断处理程序一定是内核程序,需要运行在“内核态”

    image-20210406135456448

脑图:

image-20210406135343449

1.1.6、系统调用

  1. 什么是系统调用

    操作系统作为用户和计算机硬件之间的接口,需要向上提供一些简单易用的服务。主要包括命令接口程序接口。其中,程序接口由一组系统调用组成。

    image-20210408185305701

    “系统调用”是操作系统提供给应用程序(程序员/编程人员)使用的接口,可以理解为一种可供应用程序调用的特殊函数,应用程序可以通过系统调用来请求获得操作系统内核的服务。

  2. 系统调用与库函数调用的区别

    image-20210408190637741

  3. 为什么系统调用是必须的

    生活场景:去学校打印店打印论文,你按下了WPS 的“打印”选项,打印机开始工作。
    你的论文打印到一半时,另一位同学按下了Word 的“打印”按钮,开始打印他自己的论文。

    思考:如果两个进程可以随意地、并发地共享打印机资源,会发生什么情况?

    两个进程并发运行,打印机设备交替地收到WPS 和Word 两个进程发来的打印请求,结果两篇论文的内容混杂在一起了…

    解决方法:由操作系统内核对共享资源进行统一的管理,并向上提供 “系统调用”,用户进程想要使用打印机这种共享资源,只能通过系统调用向操作系统内核发出请求。内核会对各个请求进行协调处理

  4. 什么功能要用系统调用实现

    应用程序通过系统调用请求操作系统的服务。而系统中的各种共享资源都由操作系统内核统一掌管,因此凡是与共享资源有关的操作(如存储分配、I/O操作、文件管理等),都必须通过系统调用的方式向操作系统内核提出服务请求,由操作系统内核代为完成。这样可以保证系统的稳定性和安全性,防止用户进行非法操作。

    image-20210408190445658

  5. 系统调用的过程

    image-20210408191025687

    1. 传递系统调用参数
    2. 执行陷入指令(用户态)
    3. 执行相应的内请求核程序处理系统调用(核心态)
    4. 返回应用程序

脑图:

image-20210408191123343

第一章总结

image-20210408192546560

第二章 进程与线程


在这里插入图片描述

在这里插入图片描述

2.1.1、进程的概念、组成与特征

1、定义——在计算机发展史上,”进程”是为了解决什么问题而被引入的?

1、进程的发展

在早期的计算机中,只支持单道程序。

image-20210408193824213

在引入多道程序技术之后(操作系统)

image-20210408193938895

进程与程序的区别:

  • 程序:是静态的,就是个存放在磁盘里的可执行文件,就是一系列的指令集合。
  • 进程(Process):是动态的,是程序的一次执行过程

同一个程序多次执行会对应多个进程。

2、进程的定义

**程序段、数据段、PCB三部分组成了进程实体(进程映像)**。一般情况下,我们把进程实体就简称为进程,例如:所谓创建进程,实质上是创建进程实体中的PCB;而撤销进程,实质上是撤销进程实体中的PCB。

注意:PCB是进程存在的唯一标志!

从不同的角度,进程有不同的定义,比较传统典型的定义有:(强调“动态性”)进程的正在进行

  1. 进程是程序的一次执行过程
  2. 进程是一个程序及其数据在处理机上顺序执行时所发生的活动
  3. 进程是具有独立功能的程序在数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位

引入进程实体的概念后,可把进程定义为:

进程是进程实体的运行过程,是系统进行资源分配调度的一个独立单位。

注:严格来说,进程实体和进程并不一样,进程实体是静态的进程则是动态的。不过,除非题目专门考察二者区别,否则可以认为进程实体就是进程。因此我们也可以说“进程由程序段、数据段、PCB三部分组成

2、组成——每个进程由哪些部分组成

  1. PCB(Process Control Block):操作系统使用的。进程的管理者(操作系统)所需的数据都在PCB当中

    image-20210408195302165

    image-20210408195101713

    image-20210408195206139

  2. 程序段:进程自己使用的。程序本身的运行所需的数据

    存放要执行的代码

  3. 数据段:进程自己使用的。程序本身的运行所需的数据

    存放程序运行过程中处理的各种数据

image-20210408195916760

image-20210408194909291

3、组织方式——系统中的各个进程之间是如何被组织起来的

在一个系统中,通常有数十数百乃至数千个PCB。为了能对他们加以有效的管理,应该用适当的方式把这些PCB组织起来。

注意:进程的组成讨论的是一个进程内部的由哪些部分构成的问题,而进程的组织讨论的是多个进程之间的组织方式的问题。

  • 链接方式

    按照进程状态将PCB分为多个队列

    操作系统持有指向各个队列的指针

    image-20210408200538083

  • 索引方式

    根据进程状态的不同,建立几张索引表

    操作系统持有指向各个索引表的指针

    image-20210408200615846

4、特征——相比于程序,进程有什么特征

  • 动态性:进程是程序的一次执行过程,是动态地产生、变化和消亡的
  • 并发性:内存中有多个进程实体,各进程可以并发执行
  • 独立性:进程是能独立运行、独立获得资源、独立接收调度的基本单位
  • 异步性:各进程按各自独立的,不可预知的速度向前推进,操作系统要提供”进程同步机制“来解决异步问题
  • 结构性:每个进程都会配置一个PCB。结构上看,进程由程序段、数据段、PCB组成

image-20210408200659340

脑图

image-20210408200752313

2.1.2、进程的状态与转换

1、进程的状态

进程是程序的一次执行,在这个执行过程中,有时进程正在被CPU处理,有时又需要等待CPU服务,可见,进程的状态是会有各种变化。为了方便对各种进程的管理,操作系统需要将进程合理地划分为几种状态。

进程有五种状态,其中有三种基本状态

三种基本状态:

  • 运行态(Running):占有CPU,并在CPU上运行

    注意:在单核处理机环境下,每一时刻最多只有一个进程处于运行态。(双核环境下可以同时有两个进程处于运行态)

  • 就绪态(Ready):已经具备运行条件,但由于没有空闲的CPU,而暂时不能运行

    进程已经拥有了除处理机之外所有需要的资源,一旦获得处理机,即可立即进入运行态开始运行。即:万事俱备,只欠CPU

  • 阻塞态(Waiting/Blocked,又称:等待态):因等待某一事件而暂时不能运行

    如:等待操作系统分配打印机、等待读磁盘操作的结果,CPU是计算机中最昂贵的部件,为了提高CPU的利用率,需要先将其他进程需要的资源分配到位,才能得到CPU的服务。

剩余的两种状态:

  • 创建状态(New,又称:新建态):进程正在被创建,操作系统为进程分配资源、初始化PCB

    操作系统需要完成创建进程。操作系统为该进程分配所需的内存空间等系统资源,并为其创建、初始化PCB(如:为进程分配PID)

  • 终止状态(Terminated,又称:结束态):进程正在从系统中撤销,操作系统会回收进程拥有的资源、撤销PCB

    进程运行结束(或者由于bug导致进程无法继续执行下去,比如数组越界错误,除数为0等等),需要撤销进程。

    操作系统需要完成撤销进程的相关的工作。完成将分配给进程的资源回收,撤销进程PCB等工作。

2、进程状态间的转换

image-20210408210631200

  • 就绪态 => 运行态
  • 运行态 => 就绪态
  • 运行态 => 阻塞态
  • 阻塞态 => 就绪态

注意:不能有阻塞态直接转换为运行态,也不能由就绪态直接转换为阻塞态(因为进入阻塞态是进程主动请求的,必然需要进程在运行时才能发出这种请求)

脑图

image-20210408210724237

2.1.3、进程控制

1、基本概念

1、什么是进程控制

进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程撤销已有进程实现进程状态转换等功能。

简化理解:反正进程控制就是要实现进程状态转换

image-20210408223038360

2、如何实现进程控制——原语

原语是一种特殊的程序,它的执行具有原子性。也就是说,这段程序的运行必须一气呵成,不可中断

如果不能“一气呵成”,就有可能导致操作系统中的某些关键数据结构信息不统一的情况,这会影响操作系统进行别的管理工作。

原语的执行具有原子性,即执行过程只能一气呵成,期间不允许被中断。可以用“关中断指令”和“开中断指令”这两个特权指令实现原子性

正常情况:CPU每执行完一条指令都会例行检查是否有中断信号需要处理,如果有,则暂停运行当前这段程序,转而执行相应的中断处理程序。

CPU执行了关中断指令之后,就不再例行检查中断信号,直到执行开中断指令之后才会恢复检查。

这样,关中断、开中断之间的这些指令序列就是不可被中断的,这就实现了“原子性”。

image-20210408223258148

2、进程控制相关的原语

学习技巧:进程控制会导致进程状态的转换。无论哪个原语,要做的无非三类事情:

  1. 更新PCB中的信息(如修改进程状态标准、简化运行环境保存到PCB、从PCB恢复运行环境)
    1. 所有的进程控制原语一定都会修改进程状态标准
    2. 剥夺当前运行进程的CPU使用权必然需要保存其运行环境
    3. 某进程开始运行前必然要恢复其运行环境
  2. 将PCB插入合适的队列
  3. 分配/回收资源
  • 进程的创建

    image-20210408223606502

  • 进程的终止

    image-20210408223742379

  • 进程的阻塞与唤醒

    image-20210408224532652

  • 进程的切换

    image-20210408224604511

脑图

image-20210408223947869

2.1.4、进程通信

什么是进程通信?

顾名思义,进程通信就是指进程之间的信息交换

进程是分配系统资源的单位(包括内存地址空间),因此各进程拥有的内存地址空间相互独立。为了保证安全,一个进程不能直接访问另一个进程的地址空间。但是进程之间的信息交换又是必须实现的。为了保证进程间的安全通信,操作系统提供了一些方法。如下:

1、共享存储

两个进程对共享空间的访问必须是互斥的(互斥访问通过操作系统提供的工具实现)。
操作系统只负责提供共享空间同步互斥工具(如P、V操作)

  • 基于数据结构的共享:

    比如共享空间里只能放一个长度为10的数组。这种共享方式速度慢、限制多,是一种低级通信方式

  • 基于存储区的共享:

    在内存中画出一块共享存储区,数据的形式、存放位置都由进程控制,而不是操作系统。相比之下,这种共享方式速度更快,是一种高级通信方式。

image-20210409014400967

2、消息传递

进程间的数据交换以格式化的消息(Message)为单位。进程通过操作系统提供的“发送消息/接收消息”两个原语进行数据交换。

格式化的信息包含消息头和消息体。在消息头中包括:发送进程ID、接受进程ID、消息类型、消息长度等格式化的信息(计算机网络中发送的“报文”其实就是一种格式化的消息)

  • 直接通信方式:消息直接挂到接收进程的消息缓冲队列上

    image-20210409015008639

  • 间接通信方式:消息要先发送到中间实体(信箱)中,因此也称“信箱通信方式”。Eg:计网中的电子邮件系统。

    image-20210409015029868

3、管道通信

“管道”是指用于连接读写进程的一个共享文件,又名pipe文件。其实就是在内存中开辟一个大小固定的缓冲区

image-20210409014459852

  1. 管道只能采用半双工通信,某一时间段内只能实现单向的传输。如果要实现双向同时通信,则需要设置两个管道
  2. 各进程要互斥地访问管道。
  3. 数据以字符流的形式写入管道,当管道写满时,写进程的write()系统调用将被阻塞,等待读进程将数据取走。当读进程将数据全部取走后,管道变空,此时读进程的read()系统调用将被阻塞
  4. 如果没写满,就不允许读。如果没读空,就不允许写。
  5. 数据一旦被读出,就从管道中被抛弃,这就意味着读进程最多只能有一个,否则可能会有读错数据的情况。

脑图

image-20210409015059025

2.1.5、线程的概念、特点与多线程模型

1、什么是线程?为什么要引入线程?

进程是程序的一次执行。同一进程里不同的功能显然需要用不同的几段程序才能实现,并且这几段程序还要并发运行(qq当中的视频、文字聊天、传送文件)。而且,当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存)开销很大。

image-20210409021721725

有的进程可能需要“同时”做很多事,而传统的进程只能串行地执行一系列程序。为此,引入了“线程”,来增加并发度。

image-20210409021850582

2、与进程相比,线程有什么特点?

  • 可以把线程理解为“轻量级进程”。

  • 引入线程前,进程既是资源分配的基本单位,也是调度的基本单位

  • 引入线程后,进程是资源分配的基本单位线程是调度的基本单位线程也有运行态、就绪态、阻塞态

  • 多CPU环境下,各个线程也可以分派到不同的CPU上并行地执行。

  • 线程是一个基本的CPU执行单元,也是程序执行流的最小单位

  • 引入线程后,进程只作为除CPU之外的系统资源的分配单元(如打印机、内存地址空间等都是分配给进程的)。线程则作为处理机的分配单元

  • 引入线程后,进程是资源分配的基本单位。而线程几乎不拥有资源,只拥有极少量的资源(线程控制块TCB(Thread Control Block)、寄存器信息、堆栈等

  • 引入线程之后,不仅是进程之间可以并发,进程内的各线程之间也可以并发,从而进一步提升了系统的并发度,使得一个进程内也可以并发处理各种任务(如QQ视频、文字聊天、传文件)

  • 进程间并发,开销很大。线程间并发,开销更小。进程间通信必须请求操作系统服务(CPU要切换到核心态),开销大。同进程下的线程间通信,无需操作系统干预,开销更小。引入线程机制后,并发带来的系统开销降低,系统并发性提升。

    注意:从属于不同进程的线程间切换,也必须请求操作系统服务!也会导致进程的切换!开销也大

    当切换进程时,需要保存/恢复进程运行环境,还需要切换内存地址空间(更新快表、更新缓存)

    同一进程内的各个线程间并发,不需要切换进程运行环境和内存地址空间,省时省力。

  • 从属同一进程的各个线程共享进程拥有的资源

  • 各个进程的内存地址空间相互独立,只能通过请求操作系统内核的帮助来完成进程间通信。

  • 同一进程下的各个线程间共享内存地址空间,可以直接通过读/写内存空间进行通信。

总结:

线程最小执行单位,进程最小分配资源单位

进程是可拥有资源的基本单位,频繁创建撤销进程会造成很大时空开销;而线程只是独立调度和分派的基本单位,共享进程的系统资源,线程被频繁创建和撤销也不会造成太大的时空开销。那仍然是执行的一个进程,只不过同时执行一个进程里面的多个线程。有些程序语言里还有更更轻量的协程,都是为了降低并发的代价。

3、引入线程机制后,有什么变化?

image-20210409023101820

类比:

切换进程运行环境:有一个不认识的人要用桌子,你需要你的书收走,他把自己的书放到桌上
同一进程内的线程切换=你的舍友要用这张书桌,可以不把桌子上的书收走。

4、线程有哪些重要的属性?

image-20210409023226229

5、线程的实现方式

  • 用户级线程(User-Level Thread, ULT)

    用户级线程由应用程序通过线程库实现。

    所有的线程管理工作都由应用程序负责(包括线程切换)

    用户级线程中,线程切换可以在用户态下即可完成,无需操作系统干预。

    在用户看来,是有多个线程。但是在操作系统内核看来,并意识不到线程的存在。(用户级线程对用户不透明,对操作系统透明

    可以这样理解,“用户级线程”就是“从用户视角看能看到的线程”

    image-20210409024313592

  • 内核级线程(Kernel-Level Thread, KLT, 又称“内核支持的线程”)

    内核级线程的管理工作操作系统内核完成

    线程调度、切换等工作都由内核负责,因此内核级线程的切换必然需要在核心态下才能完成。

    可以这样理解,“内核级线程”就是“从操作系统内核视角看能看到的线程”

    image-20210409024248522

线程的实现方式:

在同时支持用户级线程和内核级线程的系统中,可采用二者组合的方式:将n个用户级线程映射到m个内核级线程上(n >= m)

重点重点重点: 操作系统只“看得见”内核级线程,因此只有内核级线程才是处理机分配的单位。

例如:下边这个模型中,该进程由两个内核级线程,三个用户级线程,在用户看来,这个进程中有三个线程。但即使该进程在一个4核处理机的计算机上运行,也最多只能被分配到两个核,最多只能有两个用户线程并行执行。

image-20210409024124625

6、多线程模型

在同时支持用户级线程和内核级线程的系统中,由几个用户级线程映射到几个内核级线程的问题引出了“多线程模型”问题。

  • 多对一模型:

    多个用户及线程映射到一个内核级线程。每个用户进程只对应一个内核级线程。

    优点:用户级线程的切换在用户空间即可完成,不需要切换到核心态,线程管理的系统开销小,效率高。

    缺点:当一个用户级线程被阻塞后,整个进程都会被阻塞,并发度不高。多个线程不可在多核处理机上并行运行。

    image-20210409024313592

  • 一对一模型:

    一个用户及线程映射到一个内核级线程。每个用户进程有与用户级线程同数量的内核级线程。

    优点:当一个线程被阻塞后,别的线程还可以继续执行,并发能力强。多线程可在多核处理机上并行执行。

    缺点:一个用户进程会占用多个内核级线程,线程切换由操作系统内核完成,需要切换到核心态,因此线程管理的成本高,开销大。

    image-20210409024248522

  • 多对多模型

    n 用户及线程映射到m 个内核级线程(n >= m)。每个用户进程对应m 个内核级线程。

    克服了多对一模型并发度不高的缺点,又克服了一对一模型中一个用户进程占用太多内核级线程,开销太大的缺点。

    image-20210409024124625

脑图

image-20210409024100247

2.2.1、处理机调度的概念、层次

1、基本概念

当有一堆任务要处理,但由于资源有限,这些事情没法同时处理。这就需要确定某种规则来决定(如:VIP优先、短作业优先调等等)处理这些任务的顺序,这就是“调度”研究的问题。
在多道程序系统中,进程的数量往往是多于处理机的个数的,这样不可能同时并行地处理各个进程。处理机调度,就是从就绪队列中按照一定的算法选择一个进程并将处理机分配给它运行,以实现进程的并发执行。

2、三个层次

1、高级调度(作业调度)

image-20210504210138470

2、中级调度(内存调度)

image-20210504210203139

3、低级调度(进程调度)

image-20210504210230216

3、三层调度的联系、对比

image-20210504210333146

4、补充知识——进程的”挂起态”与七状态模型

image-20210504210257856

脑图

image-20210504210409703

2.2.2、进程调度的时机、切换与过程、方式

1、时机

1、什么时候需要进程调度

image-20210504211825916

2、什么时候不需要进程调度(但是进程在普通临界区中是可以进行调度、切换的。)

image-20210504211925883

3、临界区与内核程序临界区

image-20210504212056981

image-20210504212117707

如果还没退出临界区(还没解锁)就进行进程调度,但是进程调度相关的程序也需要访问就绪队列,但此时就绪队列被锁住了,因此又无法顺利进行进程调度。

内核程序临界区访问的临界资源如果不尽快释放的话,极有可能影响到操作系统内核的其他管理工作。因此在访问内核程序临界区期间不能进行调度与切换

image-20210504212504714

在打印机打印完成之前,进程一直处于临界区内,临界资源不会解锁。但打印机又是慢速设备,此时如果一直不允许进程调度的话就会导致CPU一直空闲。

普通临界区访问的临界资源不会直接影响操作系统内核的管理工作。因此在访问普通临界区时可以进行调度与切换

2、切换与过程

1、”狭义的调度”与”进程切换”的区别

image-20210504212743872

3、方式

1、非剥夺调度方式(非抢占式)

image-20210504212658968

2、剥夺调度方式(抢占式)

image-20210504212718957

脑图

image-20210504212903088

2.2.3、调度算法的评价指标

1、CPU利用率

image-20210504213732211

2、系统吞吐量

image-20210504213750111

3、周转时间

1、周转时间、平均周转时间

image-20210504213811576

2、带权周转时间、平均带权周转时间

image-20210504213904684

即:周转时间都是11s,但是作业1的运行时间是1s,等待时间是10s;而作业2的运行时间是10s,等待时间是1s。

image-20210504213926076

4、等待时间

image-20210504214350127

5、响应时间

image-20210504214421607

脑图

image-20210504214513619

2.2.4、调度算法:先来先服务、最短作业优先、最高响应比优先

Tips:各种调度算法的学习思路

  1. 算法思想
  2. 算法规则
  3. 这种调度算法是用于作业调度还是进程调度?
  4. 抢占式?非抢占式?
  5. 优点和缺点
  6. 是否会导致饥饿:
    • 某进程/作业长期得不到服务

1、先来先服务(First Come First Serve:FCFS)

image-20210504220945017

相关例题:

image-20210504221017275

2、短作业优先( Shortest Job First:SJF)

image-20210504221101352

相关例题

1、非抢占式的短作业优先

image-20210504221228030

2、抢占式的短作业优先

image-20210504221334542

image-20210504221401081

细节:

image-20210504221427980

3、对两种算法的思考

image-20210504221522717

4、高响应比优先(Highest Response Ratio Next:HRRN)

image-20210504221551173

相关例题:

image-20210504221620857

5、知识回顾与重要考点

image-20210504214906931

2.2.5、调度算法:时间片轮转、优先级、多级反馈队列

Tips:各种调度算法的学习思路

  1. 算法思想
  2. 算法规则
  3. 这种调度算法是用于作业调度还是进程调度?
  4. 抢占式?非抢占式?
  5. 优点和缺点
  6. 是否会导致饥饿:
    • 某进程/作业长期得不到服务

1、时间片轮转调度算法

image-20210504223036879

相关例题:

时间片为2

image-20210504223228420

image-20210504223311894

image-20210504223733579

时间片为5

image-20210504223829429

时间片的选取&时间片轮转调度算法与先来先服务算法的关系:

image-20210504224009814

image-20210504224059700

2、优先级调度算法

image-20210504224852371

相关例题:

非抢占式的优先级调度算法:

image-20210504224320500

抢占式的优先级调度算法:

image-20210504224457516

补充:

image-20210504224802316

3、思考

image-20210504224934560

4、多级反馈队列调度算法

image-20210504225527480

相关例题

image-20210504225342558

5、知识回顾与重要考点

image-20210504225811770

2.3.1、进程同步、进程互斥

1、进程同步

image-20210504230549925

2、进程互斥

image-20210504230709464

对临界资源的互斥访问,可以在逻辑上分为如下四个部分:

image-20210504230942497

为了实现对临界资源的互斥访问,同时保证系统整体性能,需要遵循以下原则:

image-20210504231106627

脑图

image-20210504231143008

2.3.2、进程互斥的软件实现方法

学习提示:

  1. 理解各个算法的思想、原理
  2. 结合上小节学习的“实现互斥的四个逻辑部分”,重点理解各算法在进入区、退出区都做了什么
  3. 分析各算法存在的缺陷(结合“实现互斥要遵循的四个原则”进行分析)

1、单标志法

image-20210504231820629

image-20210504231746318

2、双标志先检查

image-20210504232145546

3、双标志后检查

image-20210504232413700

4、Peterson算法

image-20210504232720257

image-20210504232756435

image-20210504232829595

image-20210504232858921

脑图

image-20210504232928285

2.3.3、进程互斥的硬件实现方法

学习提示:

  1. 理解各方法的原理
  2. 了解各方法的优缺点

1、中断屏蔽方法

image-20210504233615762

2、TestAndSet(TS指令/TSL指令TestAndSetLock)

image-20210504233641846

3、Swap指令(XCHG指令)

image-20210504233700709

脑图

image-20210504233729313

2.3.4、信号量机制

复习回顾+思考:之前学习的这些进程互斥的解决方案分别存在哪些问题?

  • 进程互斥的四种软件实现方式(单标志法、双标志先检查、双标志后检查、Peterson算法 )
  • 进程互斥的三种硬件实现方式(中断屏蔽方法、TS/TSL指 令、Swap/XCHG指令)
    • 在双标志先检查法中,进入区的“检查”、“ 上锁”操作无法一气呵成,从而导致了两个进程有可能同时进入临界区的问题;
    • 所有的解决方案都无法实现“让权等待”

1965年,荷兰学者Dijkstra提出了一种卓有成效的实现进程互斥、同步的方法——信号量机制

1、信号量机制

image-20210504234407772

2、整型信号量

image-20210504235542982

3、纪录型信号量

image-20210504235617935

image-20210505000153209

image-20210505000232848

image-20210505000249924

脑图

image-20210505000323832

2.3.5、用信号量实现进程互斥、同步、前驱关系

Tips:不要一头钻到代码里,要注意理解信号量背后的含义,一个信号量对应一种资源

信号量的值 = 这种资源的剩余数量(信号量的值如果小于0,说明此时有进程在等待这种资源)

P(S)——申请一个资源S,如果资源不够就阻塞等待
V(S)——释放一个资源S,如果有进程在等待该资源,则唤醒一个进程

1、实现进程互斥

image-20210505001722795

2、实现进程同步

image-20210505001748039

image-20210505001810855

3、实现进程的前驱关系

image-20210505001831897

脑图

image-20210505001903980

2.3.6、生产者-消费者问题

1、问题描述

image-20210505004917915

2、问题分析

image-20210505003435388

image-20210505004125366

image-20210505004950707

3、问题解决

image-20210505005740558

4、思考:能否改变相邻P、V操作的顺序?

image-20210505005811868

“使用产品”能不能放在”取出产品之后”(即PV操作之间):

从逻辑上来说没什么问题,取出一个产品之后马上使用。但是最好不要。因为这会导致临界区的代码量变大,消费者进程在访问临界区资源的时候就会耗费更长的时间,如果此时有别的进程也想访问临界区资源的话是会被阻塞的。

把这些非必要的代码放进临界区的话,就显然会导致进程间的并发度降低,所以最好不要把没有必要的代码放到临界区里面。

5、知识回顾与重要考点

image-20210505004859079

2.3.7、生产者-多消费者

1、问题描述

image-20210505011453970

2、问题分析

image-20210505011627140

image-20210505011725280

3、问题解决

方式一:使用互斥信号量mutex

image-20210505012011434

方式二:不使用互斥信号量mutex

image-20210505012224581

为什么只可以使用(同步)信号量plate,省略(异步)信号量mutex解决问题呢?

原因在于:本题中的缓冲区大小为1,在任何时刻,apple、 orange、 plate三个同步信号量中最多只有一个是1。因此在任何时刻,最多只有一个进程的P操作不会被阻塞,并顺利地进入临界区。

如果把缓冲区的大小设置为2,即盘子里面可以放置两个水果

image-20210505012546283

4、知识回顾与重要考点

image-20210505011103373

image-20210505011126965

2.3.8、吸烟者问题

1、问题描述

image-20210505013010023

2、问题分析

image-20210505013811237

image-20210505013903213

3、问题解决

image-20210505013926731

4、知识回顾与重要考点

image-20210505012943618

2.3.9、读者-写者问题

1、问题描述

image-20210505014140357

2、问题分析

image-20210505014119129

3、问题解决

image-20210505014207251

image-20210505014235757

4、知识回顾与重要考点

image-20210505014049722

2.3.10、哲学家进餐问题

1、问题描述

image-20210505015934159

2、问题分析

image-20210505020031496

3、问题解决

image-20210505020244129

image-20210505020339544

第一种情况:0号进程拿起左右两支筷子进行吃饭(顺利进行)

image-20210505020554497

第二种情况:在第一种情况的基础下,0号进程正在吃饭,此时1号进程想要吃饭,但是会被阻塞在拿左边筷子的语句P(chopstick[i])上,此时2号进程想要吃饭,但是由于1号进程执行了P(mutex)但是没执行V(mutex),所以2号进程会被阻塞在语句P(mutex)上,即:2号进程虽然左右两边都有筷子,但是它吃不了饭。

image-20210505021347456

第三种情况:在第一种的情况下,4号进程想吃饭,它会拿起左边的筷子,然后就被阻塞在拿右边筷子的语句P(chopstick[(i+1)%5])上了

image-20210505021537145

4、知识回顾与重要考点

image-20210505021821006

2.3.11、管程

1、为什么要引入管程

image-20210505022123552

2、管程的定义和基本特征

image-20210505022141694

3、拓展1:用管程解决生产者消费者问题

image-20210505022231170

image-20210505022251625

4、拓展2:java中类似于管程的机制

image-20210505022319316

脑图

image-20210505022210103

2.4.1、死锁的概念

1、什么是死锁

image-20210505143529683

2、进程死锁、饥饿、死循环的区别

image-20210505143807098

3、死锁产生的必要条件

image-20210505144019761

4、什么时候会发生死锁

image-20210505144120239

5、死锁的处理策略

image-20210505144149160

脑图

image-20210505144218705

2.4.2、死锁的处理策略——预防死锁

1、死锁的处理

image-20210505144445903

2、破坏互斥条件

image-20210505144725993

3、破坏不剥夺条件

image-20210505145005522

4、破坏请求和保持条件

image-20210505145224511

5、破坏循环等待条件

image-20210505145453127

脑图

image-20210505145752465

2.4.3、死锁的处理策略——避免死锁

1、动态策略:避免死锁

image-20210505145945572

2、什么是安全序列

image-20210505150922419

image-20210505151042960

image-20210505151124895

image-20210505151244599

3、安全序列、不安全状态、死锁的联系

image-20210505151529696

4、银行家算法

image-20210505151659990

image-20210505151838002

image-20210505151927039

不安全的情况:

image-20210505152108400

代码实现:

image-20210505152424001

5、知识回顾与重要考点

image-20210505152609859

2.4.4、死锁的处理——策略检测和解除

1、死锁的检测和解除

image-20210505152730364

2、死锁的检测

image-20210505152953706

没有发生死锁:

image-20210505153349861

发生死锁:

image-20210505153629990

死锁定理:

image-20210505153748812

3、死锁的解除

image-20210505154108164

脑图

image-20210505154238217

第三章 内存管理

img

在这里插入图片描述

3.1.1、内存的基础知识

1、什么是内存?内存的作用——存储单元与内存地址

image-20210508002901851

2、进程运行的基本原理

1、指令的工作原理

image-20210508003140541

image-20210508003207444

image-20210508003230122

2、逻辑地址 VS 物理地址

image-20210508004518010

3、如何实现地址转换

image-20210508004749695

image-20210508004832543

4、从写程序到程序运行的过程:编辑——编译——链接——装入

image-20210508004555654

5、三种装入方式
1、三种装入方式——绝对装入

image-20210508005036546

2、三种装入方式——可重定位装入

image-20210508005056249

3、三种装入方式——动态运行时装入

image-20210508005114414

image-20210508005143082

6、三种链接方式
1、三种链接方式——静态链接

image-20210508005324193

2、三种链接方式——装入时动态链接

image-20210508005341688

3、三种链接方式——运行时动态链接

image-20210508005409340

3、补充知识:几个常用的数量单位

image-20210508002939003

脑图

image-20210508005509266

3.1.2、内存管理的概念

1、内存空间的分配与回收

image-20210508010417701

2、内存空间的扩充

image-20210508010447793

3、地址转换

image-20210508010631873

4、存储保护

image-20210508010712814

1、存储保护——方式1

image-20210508010856791

2、存储保护——方式2

image-20210508011111324

脑图

image-20210508005842818

3.1.3、覆盖与交换

1、内存空间的扩充

image-20210508083942705

1、覆盖技术

image-20210508012819104

image-20210508012843411

2、交换技术

image-20210508012931347

image-20210508012947320

image-20210508013003422

脑图

image-20210508011536720

3.1.4、内存空间的分配与回收——连续分配管理方式

image-20210508084011062

3.1.4.1.1、连续分配管理方式——单一连续分配

image-20210508090339927

3.1.4.1.2、连续分配管理方式——固定分区分配

image-20210508090358501

image-20210508090412868

3.1.4.1.3、连续分配管理方式——动态分区分配

image-20210508090450612

1、动态分区分配问题1——系统要用什么样的数据结构记录内存的使用情况?

image-20210508090546098

2、动态分区分配问题2——当很多个空闲分区都能满足需求时,应该选择哪个分区进行分配?

image-20210508090650871

3、动态分区分配问题3——如何进行分区的分配与回收操作?
1、分配——分配到相对大的空间中

image-20210508090911675

2、分配——分配到刚刚好的空间中

image-20210508091146057

3、回收——回收区的后面有一个相邻的空闲分区

image-20210508091905607

4、回收——回收区的前面有一个相邻的空闲分区

image-20210508091821239

5、回收——回收区的前、后面各有一个相邻的空闲分区

image-20210508092028487

6、回收——回收区的前、后都没有相邻的空闲分区

image-20210508092245786

7、内部碎片与外部碎片

image-20210508093103201

image-20210508093318950

脑图

image-20210508093401065

3.1.5、动态分区分配算法

image-20210508093523120

1、首次适应算法(First Fit)

image-20210508094125510

2、最佳适应算法(Best Fit)

image-20210508095209932

image-20210508095244859

3、最坏适应算法(Worst Fit)

image-20210508095837523

image-20210508095921779

4、邻近适应算法(Next Fit)

image-20210508100315188

image-20210508102952416

image-20210508103130003

5、知识回顾与重要考点

image-20210508100431925

3.1.4、内存空间的分配与回收——非连续分配管理方式

image-20210508105913844

image-20210508103253793

3.1.4.2.1、非连续分配管理方式——基本分页存储管理方式

把“固定分区分配”改造为“非连续分配版本”

image-20210508110554274

1、基本分页存储管理的基本概念

image-20210508111128672

2、如何实现地址的转换

进程在内存中连续存放时:

image-20210508111333219

主要思想:模块在内存中的的“起始地址”+ 目标内存单元相对于起始位置的“偏移量”

进程在内存中不连续存放时(采用分页存储):

image-20210508111929405

image-20210508112229037

image-20210508112510315

image-20210508112743184

结论:如果每个页面大小为2KB,用二进制数表示逻辑地址,则末尾K位即为页内偏移量,其余部分就是页号。
因此,如果让每个页面的大小为2的整数幂,计算机就可以很方便地得出一个逻辑地址对应的页号和页内偏移量。

3、逻辑地址结构

image-20210508113103944

4、页表

image-20210508113253320

image-20210508113414039

脑图

image-20210508113608275

3.1.6、基本地址变换机构

基本地址变换机构:用于实现逻辑地址到物理地址转换的一组硬件机构

image-20210508133337702

image-20210508133401880

image-20210508132845186

image-20210508132931955

image-20210508133601876

image-20210508133911049

脑图

image-20210508134019463

3.1.7、具有快表的地址变换机构

1、局部性原理

image-20210508202404524

2、快表(TLB)

image-20210508225440701

image-20210508225501750

3、引入快表后,地址的变换过程

image-20210508225813674

4、知识回顾与重要考点

image-20210508225906098

3.1.8、两级页表

1、单级页表存在什么问题?如何解决?

image-20210508234002478

image-20210508235354588

image-20210508235432906

2、两级页表的原理、逻辑地址结构

image-20210508235458794

image-20210508235527071

3、如何实现地址变换?

image-20210509002703906

4、如何解决单级页表的问题?

image-20210509002832170

5、两级页表问题需要注意的几个细节

image-20210509003331829

脑图

image-20210509003459726

3.1.4.2.2、非连续分配管理方式——基本分段存储管理方式

1、分段

image-20210509004013708

image-20210509004209165

2、段表

image-20210509004817652

3、地址变换

image-20210509004846961

image-20210509004926128

4、分段、分页管理的对比

image-20210509005230009

image-20210509005348731

image-20210509005415434

image-20210509005436167

脑图

image-20210509005500533

3.1.4.2.3、非连续分配管理方式——段页式管理方式

1、分页、分段的优缺点分析

image-20210509005829321

image-20210509005855867

2、分段+分页=段页式管理

image-20210509005945705

3、段页式管理的逻辑地址结构

image-20210509010126138

4、段表、页表

image-20210509010510498

image-20210509010923423

脑图

image-20210509011052374

3.2.1、虚拟内存的基本概念

image-20210509011129396

1、传统存储管理方式的特征、缺点

image-20210509011637911

2、局部性原理

image-20210509011701550

3、虚拟内存的定义和特征

image-20210509011935455

image-20210509012047548

4、如何实现虚拟内存技术

image-20210509012152298

脑图

image-20210509012242692

3.2.2、请求分页管理方式

image-20210509012615162

1、页表机制

image-20210509012717541

2、缺页中断机构

内存中存在空闲块:

image-20210509013338043

image-20210509012743561

内存中不存在空闲块:

image-20210509013627907

image-20210509012801635

image-20210509012828125

3、地址变换机构

image-20210509012911260

image-20210509013853533

image-20210509013943280

image-20210509014238955

image-20210509014011536

脑图

image-20210509012956979

3.2.3、页面置换算法

image-20210509014356859

1、页面置换算法——最佳置换算法(OPT)

image-20210509014635661

image-20210509014650392

2、页面置换算法——先进先出置换算法(FIFO)

image-20210509014718214

image-20210509014853923

3、页面置换算法——最近最久未使用置换算法(LRU)

image-20210509014927703

4、页面置换算法——时钟置换算法(CLOCK)

image-20210509015030833

5、页面置换算法——改进型的时钟置换算法

image-20210509021015176

image-20210509021320133

image-20210509021715893

image-20210509022026524

image-20210509022148622

6、知识回顾与重要考点

image-20210509015408768

3.2.4、页面分配策略

image-20210509022248848

1、页面分配、置换策略

image-20210509022843592

image-20210509023042278

image-20210509023615272

image-20210509023233829

2、何时调入页面

image-20210509022356148

预调页策略和请求调页策略一般会结合着进行使用。

image-20210509022420725

image-20210509022441309

image-20210509022505874

3、抖动(颠簸)现象

image-20210509022535523

4、工作集

image-20210509022604479

脑图

image-20210509022634991

第四章 文件管理

在这里插入图片描述

4.1.1、初识文件管理

image-20210510172015086

1、Windows操作系统的文件管理

image-20210510173929518

2、文件的属性

image-20210510172305330

image-20210510172426075

3、文件内部的数据应该怎样组织起来?

image-20210510173839817

image-20210510173958010

4、文件之间应该怎样组织起来?

image-20210510174052984

image-20210510174111553

5、操作系统应该向上提供哪些功能?

image-20210510174132287

image-20210510174152635

6、从上往下看,文件应如何存放在外存?

image-20210510174208743

image-20210510174229192

7、其他需要由操作系统实现的文件管理功能

image-20210510174244099

脑图

image-20210510174258642

4.1.2、文件的逻辑结构

image-20210510174403271

1、无结构文件

image-20210510174420173

2、有结构文件

image-20210510174420173

image-20210510174511007

image-20210510174533798

3、有结构文件的逻辑结构

image-20210510174614541

4、顺序文件

image-20210510174647781

image-20210510174712891

5、索引文件

image-20210510174742627

6、索引顺序文件

image-20210510174824341

7、索引顺序文件(检索效率分析)

image-20210510174900571

8、多级索引顺序文件

image-20210510174937291

脑图

image-20210510175000352

image-20210510175034205

4.1.3、文件目录

image-20210510180705930

image-20210510180724124

1、文件控制块

image-20210510180808309

image-20210510181628502

image-20210510181647855

2、目录结构——单级目录结构

image-20210510181719480

3、目录结构——两级目录结构

image-20210510181749558

4、目录结构——多级目录结构

image-20210510181840085

image-20210510181919035

image-20210510181942460

5、目录结构——无环图目录结构

image-20210510182039884

6、索引结点(FCB的改进)

image-20210510182124127

image-20210510182257634

脑图

image-20210510182328384

4.1.4、文件的物理结构(文件分配方式)

image-20210510184057179

image-20210510184143772

1、文件块、磁盘块

image-20210510184202356

image-20210510184234195

2、文件分配方式——连续分配

image-20210510184517016

image-20210510194304195

image-20210510194321776

image-20210510194418244

总结:

image-20210510194438017

3、文件分配方式——链接分配

链接分配采取离散分配的方式,可以为文件分配离散的磁盘块。分为隐式链接显式链接两种。

4、链接分配——隐式链接

image-20210510200010895

image-20210510200055678

image-20210510200134964

5、链接分配——显式链接

image-20210510200212233

image-20210510200226440

6、链接分配(总结)

image-20210510200242086

7、文件分配方式——索引分配

image-20210510200330776

image-20210510200346364

image-20210510200418312

1、索引分配——链接方案

image-20210510200434973

2、索引分配——多层索引

image-20210510200449834

3、索引分配——混合索引

image-20210510203950212

8、索引分配(总结)

image-20210510200553830

9、知识点回顾与重要考点

image-20210510200625936

10、易混难点:支持随机访问

image-20210510200653421

4.1.5、文件存储空间管理

image-20210510204351623

image-20210510204422297

1、存储空间的划分与初始化

image-20210510204534899

2、存储空间管理——空闲表法

分配:

image-20210510204736595

回收:

image-20210510204759316

image-20210510204857398

image-20210510204918112

3、存储空间管理——空闲链表法

image-20210510205032206

1、空闲链表法——空闲盘块链

分配与回收:

image-20210510205148716

2、空闲链表法——空闲盘区链

分配与回收:

image-20210510205259520

4、存储空间管理——位示图法

image-20210510205536697

分配与回收:

image-20210510205642666

5、存储空间管理——成组链接法

image-20210510205738193

分配:

image-20210510210218546

image-20210510210320053

image-20210510210351181

回收:

image-20210510210747340

image-20210510210839939

image-20210510210915503

脑图

image-20210510211017588

4.1.6、文件的基本操作

image-20210510211109649

1、创建文件(create系统调用)

image-20210510211530217

2、删除文件(delete系统调用)

image-20210510211600032

3、打开文件(open系统调用)

image-20210510211623264

image-20210510211657842

4、关闭文件(close系统调用)

image-20210510211854855

5、读文件(read系统调用)

image-20210510211726508

6、写文件(write系统调用)

image-20210510211835280

脑图

image-20210510211746762

4.1.7、文件共享

image-20210510213241349

1、基于索引结点的共享方式(硬链接)

image-20210510213311954

2、基于符号链的共享方式(软链接)

image-20210510213347172

image-20210510213403610

image-20210510213429991

image-20210510213445471

image-20210510213503692

脑图

image-20210510213537878

4.1.8、文件保护

image-20210510213617783

1、口令保护

image-20210510214250894

2、加密保护

image-20210510214328225

image-20210510214402046

3、访问控制

image-20210510214438312

image-20210510214452912

4、Windows的访问控制

image-20210510214521631

image-20210510214610135\

image-20210510214635252

image-20210510214651156

脑图

image-20210510214712283

4.1.9、文件系统的层次结构

1、文件系统的层次结构

image-20210510215918389

2、知识点回顾与重要考点

image-20210510220046997

4.2.1、磁盘的结构

image-20210510214754390

1、磁盘、磁道、扇区

image-20210510220120936

2、如何在磁盘中读/写数据

image-20210510220141125

3、盘面、柱面

image-20210510220307505

image-20210510220318911

4、磁盘的物理地址

image-20210510220334460

5、磁盘的分类

image-20210510220354743

image-20210510220409094

脑图

image-20210510220429976

4.2.2、磁盘调度算法

image-20210510220523176

1、一次磁盘读/写操作需要的时间

image-20210510221358877

image-20210510221414332

image-20210510221430472

image-20210510221444551

2、先来先服务算法(FCFS)

image-20210510221458167

3、最短寻找时间优先(SSTF)

image-20210510221515203

4、扫描算法(SCAN)

image-20210510221530897

5、LOOK调度算法

image-20210510221552395

6、循环扫描算法(C-SCAN)

image-20210510221622445

7、C-LOOK调度算法

image-20210510221637101

脑图

image-20210510221654417

4.2.3、减少磁盘延迟时间的方法

image-20210510221738068

1、减少延迟时间的方法:交替编号

image-20210510223254723

2、磁盘地址结构的设计

image-20210510223311665

image-20210510223329605

image-20210510223343018

3、减少延迟时间的方法:错位命名

image-20210510223402335

image-20210510223414358

脑图

image-20210510223427091

4.2.4、磁盘的管理

image-20210510224338889

1、磁盘初始化

image-20210510224546394

2、引导块

image-20210510224644253

image-20210510224801174

3、坏块的管理

image-20210510224912088

脑图

image-20210510224948374

第五章 I/O管理

img

5.1.1、I/O设备的概含和分类

image-20210511110804479

1、什么是I/O设备

image-20210511111043854

image-20210511111059679

2、I/O设备的分类——按使用特性

image-20210511111151164

3、I/O设备的分类——按传输速率分类

image-20210511111340325

4、I/O设备的分类——按信息交换的单位分类

image-20210511111357178

脑图

image-20210511111317058

5.1.2、IO控制器

image-20210511111508063

1、I/O设备的机械部件

image-20210511112240342

2、I/O设备的电子部件(I/O控制器)

image-20210511112302574

3、I/O控制器的组成

image-20210511112323795

image-20210511112339935

4、内存映像I/O VS 寄存器独立编址

image-20210511112409008

脑图

image-20210511111721235

5.1.3、IO控制方式

image-20210511112526466

1、程序直接控制方式

image-20210511114118768

image-20210511114144636

image-20210511114203481

2、中断驱动方式

image-20210511114219032

image-20210511114237657

3、DMA方式

image-20210511114254744

DMA控制器

image-20210511114311516

image-20210511114331876

4、通道控制方式

image-20210511114349325

image-20210511114404292

5、知识点回顾与重要考点

image-20210511114426856

5.1.4、IO软件层次结构

image-20210511114507050

1、用户层软件

image-20210511120145365

2、设备独立性软件

image-20210511120201000

image-20210511120214522

image-20210511120232118

image-20210511120247174

image-20210511120308807

image-20210511120347783

image-20210511120406389

image-20210511120422617

3、思考:为何不同的设备需要不同的设备驱动程序?

image-20210511120439359

image-20210511120454305

image-20210511120511147

image-20210511120524487

4、设备驱动程序

image-20210511120542492

5、中断处理程序

image-20210511120601060

1、知识点回顾与重要考点

image-20210511120620181

image-20210511120633229

2、中断处理程序

image-20210511120645686

5.1.5、I/O核心子系统

image-20210511200617750

1、这些功能要在哪个层次实现?

image-20210511201151526

2、I/O调度

image-20210511201224315

3、设备保护

image-20210511201256428

4、知识总览

image-20210511201346880

5.1.6、假脱机技术

image-20210511201414648

1、什么是假脱机技术

image-20210511201503446

image-20210511201645994

2、假脱机技术——输入井和输出井

image-20210511201850384

image-20210511201805698

image-20210511201917534

image-20210511201943528

3、假脱机技术——输入/输出缓冲区

image-20210511202055139

4、共享打印机原理分析

image-20210511202209054

image-20210511202654105

image-20210511202423159

脑图

image-20210511202456002

5.1.7、设备的分配与回收

image-20210511134320520

1、设备分配时应考虑的因素

image-20210511184505182

image-20210511184525700

image-20210511184540600

2、静态分配与动态分配

image-20210511184556436

3、设备分配管理中的数据结构

image-20210511184610941

image-20210511184623290

image-20210511184641395

image-20210511184653652

image-20210511184714960

4、设备分配的步骤

image-20210511185859000

image-20210511185912320

image-20210511185925978

image-20210511185938379

5、设备分配步骤的改进

image-20210511185955148

image-20210511190008933

image-20210511190025223

脑图

image-20210511190059629

5.1.8、缓冲区管理

image-20210511184150637

1、什么是缓冲区?有什么作用?

image-20210511190337006

2、缓冲区有什么作用?

image-20210511190349446

3、单缓冲

image-20210511190403433

image-20210511190415564

image-20210511190430654

image-20210511190447737

4、双缓冲

image-20210511190502280

image-20210511190513456

image-20210511190526272

5、使用单/双缓冲在通信时的区别

image-20210511190537821

image-20210511190551862

6、循环缓冲区

image-20210511190605054

7、缓冲池

image-20210511221515991

image-20210511221647687

image-20210511221825920

image-20210511221937054

脑图

image-20210511190720738

参考链接:

bilibili王道考研

操作系统思维导图—(零基础—思维导图详细版本及知识点)

[TOC]

第一章 概述

在这里插入图片描述


1.1.1 、概念、组成、功能和分类

  1. 计算机网络概念:

    一个将分散的、具有独立功能的计算机系统,通过通信设备(交换机、路由器)与线路连接起来,由功能完善的软件实现资源共享和信息传递的系统

    计算机网络是互连的、自治的计算机集合

    image-20210402030633726

  2. 计算机网络的功能

    1. 数据通信(连通性)
    2. 资源共享(硬件、软件、数据,三大资源共享)
    3. 分布式处理(多台计算机各自承担同一工作任务的不同部分 如:Hadoop平台)
    4. 提高可靠性(替代机)
    5. 负载均衡

    image-20210402031009538

  3. 组成

    1. 组成部分(硬件、软件、协议)

      1. 硬件:主机(端系统),链路(双绞线、光纤),通信设备(路由器,交换机)
      2. 软件:QQ,微信等
      3. 协议

      image-20210402031120533

    2. 工作方式

      1. 边缘部分:用户直接使用(C/S方式,P2P方式)
      2. 核心部分:为边缘部分服务(网络,路由器,交换机)

      image-20210402031159369

    3. 功能组成

      1. 通信子网:实现数据通信(OSI上三层)

      2. 资源子网:实现资源共享/数据处理(OSI下三层)

        传输层:是资源子网和通信子网的接口

      image-20210402031240261

    4. 计算机网络的分类

      1. 按分布范围分

        • 广域网(WAN,交换技术)

        • 城域网(MAN)

        • 局域网(LAN,广播技术)

        • 个人区域网(PAN)

      2. 按使用者分

        • 公用网
        • 专用网
      3. .按交换技术分

        • 电路交换
        • 报文交换
        • 分组交换
      4. 按拓扑结构分

        • 总线型
        • 星型
        • 环形
        • 网状型(常用于广域网)
      5. 按照传输技术分

        • 广播式网络(共享公共通信信道)
        • 点对点网络(使用分组存储转发和路由选择机制)

      image-20210402031749128

脑图:

在这里插入图片描述

1.1.2、 标准化工作及相关组织

  1. 标准化工作

    1. 标准的分类

      1. 法定标准

        由权威机构指定的正式的、合法的标准

        OSI

      2. 事实标准

        某些公司的产品在竞争中占据了主流,时间长了,这些产品中的协议和技术就成了标准

        TCP/IP

  2. RFC(Request For Comments)——因特网标准的形式

RFC要上升为因特网正式标准的四个阶段

  1. 因特网草案(Internet Draft)

    这个阶段还不是RFC文档,只是一个构思

  2. 建议标准(Proposed Standard)

    - 从这个阶段开始成为RFC文档
  3. 草案标准(Draft Standard)(现取消)

    • IETF、IAB审核
  4. 因特网标准(Internet Standard)

  5. 标准化工作的相关组织

    1. 国际标准化组织ISO

      OSI参考模型、HDLC协议

    2. 国际电信联盟ITU

      制定通信规则

    3. 国际电气电子工程师协会IEEE

      学术机构、IEEE802标准、5G

    4. Internet工程任务组IETF

      负责因特网相关标准的制定 RFC—XXXX

      image-20210405164611745

脑图:

image-20210405164728541

1.1.3、 计算机网络的相关性能指标

  1. 速率

    即数据率或称数据传输率或比特率

    连接在计算机网络上的主机在数字信道上传输数据位数的速率

    补充:

    ​ 速率的单位(千、兆、吉、太)

    ​ 1Tb/s=10^3Gb/s=10^6Mb/s=10^9kb/s=10^12b/s

    ​ 存储容量单位:

    ​ 1Btye=8 bit 1TB/s=2^10 GB/s=2^20 MB/s=2^30 KB/s=2^40 B/s

  2. 带宽

    原本指某个信号具有的频带宽度,即最高频率与最低频率之差,单位是赫兹(Hz)

    在计算机网络中,带宽用来表示网络的通信线路传送数据的能力

    通常是指单位时间内从网络的某一点到另一点所能通过的“最高数据率”

    单位与速率的单位相同。(比特每秒、b/s,kb/s,Mb/s,Gb/s)

    可以理解为:网络设备所支持的最高速度,即:发送的速率

  3. 吞吐量

    表示在单位时间内通过某个网络(信道、接口)的数据量。单位b/s,kb/s,Mb/s等

    吞吐量受网络的带宽或网络的额定速率的限制

    所有的链路加一起才是本次网络的数据的真正吞吐量

  4. 时延

    指数据(报文/分组/比特流)从网络(或链路)的一端传送到另一端所需时间。
    也叫延迟或者迟延,单位是s

    1. 发送时延/传输时延 = 数据长度/信道带宽(发送速率)

      从发送分组的第一个比特算起,到该分组的最后一个比特发送完毕所需的时间。

    2. 传播时延 = 信道长度/电磁波在信道上的传播速率;
      取决于电磁波传播速度和链路长度

      此处注意:传输时延传播时延的区别

      • 传输时延:发生在主机内部,一般是发生在网络适配器当中,发生在机器内部的发送器里面
      • 传播时延:发生在机器外部,发送在信道上
    3. 排队时延

      等待输出/输入链路可用

      路由器的缓存空间那里

    4. 处理时延

      • 检错找出口
    5. 时延抖动

      时延的不均匀性

    注意:高速链路(提高发送速率/信号带宽)只是降低了发送时延,对传播时延和传播速率没有影响

  5. 时延带宽积 = 传播时延 * 带宽

    时延带宽积又称为比特位长度的链路长度

    即:某段链路现在有多少比特,有“容量”的意思

    image-20210405173609630

  6. 往返时延RTT

    发送发发送数据开始,到发送方收到接收方的确认(接收方收到数据后立即发送确认)总共经历的时延RTT越大,在收到确认之前,可以发送的数据越多

    RTT包括

    1. 往返传播时延 = 传播时延 * 2
    2. 末端处理时间

    注意:RTT不包括传输时延

  7. 利用率

    • 信道利用率 = 有数据通过时间 / (有 + 无)数据通过的时间

    • 网络利用率 = 信道利用率加权平均值

    • 利用率如果趋近于1,时延会急剧增大

      这些性能指标可以分为三类

      1. 速率、带宽、吞吐率
      2. 时延、时延带宽积、往返时延RTT
      3. 利用率(利用率如果趋近于1,时延会急剧增大)

脑图:

11

1.2.1、 分层结构、协议、接口、服务

  1. 为什么要分层?

    发送文件前要完成的工作:

    1. 发起通信的计算机必须将数据通信的通路进行激活。
    2. 要告诉网络如何识别目的主机。
    3. 发起通信的计算机要查明目的主机是否开机,并且与网络连接正常。
    4. 发起通信的计算机要弄清楚,对方计算机中文件管理程序是否已经做好准备工作。
    5. 确保差错和意外可以解决。
    6. …….

    所以,在发送文件的过程中,会出现很多问题,需要把这些问题分成一个个小问题,然后解决

  2. 怎么分层

    • 实体、对等实体
    • 对等实体之间才会有协议
    • 上下层之间的接口
    • 下层给上层提供服务
  3. 分层的基本原则

    1. 各层之间相互独立,每层只实现一种相对独立的功能
    2. 每层之间的界面自然清晰,易于理解,相互交流尽可能少
    3. 结构上可分隔开。每层都采用最合适的技术来实现
    4. 保持下层上层的独立性,上层单向使用下层提供的服务
    5. 整个分层结构应该促进标准化工作。
  4. 正式认识分层结构

    1. 实体:第n层中的活动元素称为n层实体。同一层的实体叫对等实体
    2. 协议:为进行网络中的对等实体数据交换为建立的规则、标准或约定称为网络协议。【水平
      • 语法:规定传输数据的格式
      • 语义:规定所要完成的功能
      • 同步:规定各种操作的顺序
    3. 接口(访问服务点SAP):上层使用下层服务的入口。
    4. 服务:下层为相邻上层提供的功能调用。【垂直
    5. SDU、PCI、PDU
      • SDU服务数据单元:为完成用户所需要的功能而应传输的数据。
      • PCI协议控制信息:控制协议操作的信息。
      • PDU协议数据单元:对等层次之间传送的数据单位。
      • PDU=SDU+PCI

    image-20210405222724021

总结:

  • 网络体系结构是从功能上描述计算机网络结构。
  • 计算机网络体系结构简称网络体系结构是分层结构
  • 每层遵循某个/些网络协议以完成本层功能。
  • 计算机网络体系结构是计算机网络的各层及其协议的集合。
  • 第n层在向n+1层提供服务时,此服务不仅包含第n层本身的功能,还包含由下层服务提供的功能
  • 仅仅相邻层间有接口,且所提供服务的具体实现细节对上一层完全屏蔽。
  • 体系结构是抽象的,而实现是指能运行的一些软件和硬件。

脑图:

image-20210405223601395

1.2.2、 OSI参考模型

  1. 计算机网络分层结构

    • 7层OSI参考模型(法定标准)
    • 4层TCP/IP参考模型(事实标准)
    • 5层体系结构(主要是使我们学习计算机网络更加清晰,不是事实标准,也不是法定标准)
  2. OSI参考模型是怎么来的?
    提出第一个网络体系结构:SNA(IBM公司)之后,很多公司和机构纷纷提出自己的网络体系结构:DEC公司的DNA,美国国防部的TCP/IP。为了支持异构网络系统的互联互通,国际标准化组织(ISO)于1984年提出开放系统互连(OSI)参考模型。但是,理论成功,市场失败。

  3. OSI7层结构

    1. 物理层、数据链路层、网络层、传输层、会话层、表示层、应用层
      物、链、网、输、会、示、用
      物联网淑慧试用
    2. 资源子网(数据处理):上三层:会话层、表示层、应用层
    3. 通信子网(数据通信):下三层:物理层、数据链路层、网络层
  4. OSI参考模型解释通信过程

    在这里插入图片描述

    上四层实现的是端到端的通信
    下三层实现的是点到点的通信

    在这里插入图片描述

  5. 各层功能与协议

    1. 应用层
      用户与网络的界面,所有能和用户交互产生网络流量的程序
      典型应用层服务:

      • 文件传输(FTP)
      • 电子邮件(SMTP)
      • 万维网(HTTP)等
    2. 表示层
      用于处理在两个通信系统中交换信息的表示方式(语法和语义)

      • 功能1:数据格式变换(翻译官)
      • 功能2:数据加密和解密
      • 功能3:数据压缩和解压缩

      没有专门的协议,硬要说的话:主要协议有JPEG、ASCII

    3. 会话层
      向表示层实体/用户进程提供建立连接并在连接上有序传输数据
      这是会话,也是建立同步(SYN)

      • 功能1:建立、管理、终止会话
      • 功能2:使用校验点可以使会话在通信失效时,从校验点/同步点继续恢复通信,实现数据同步。(适用于传输大文件)
        主要协议:ADSP、ASP
    4. 传输层
      负责两个不同主机中两个进程的通信,即端到端的通信。传输单位是报文段或用户数据报。(功能:可差流用)

      • 功能1:可靠传输、不可靠传输
      • 功能2:差错控制
      • 功能3:流量控制
      • 功能4:复用分用
        • 复用:多个应用层进程可同时使用下面运输层的服务。
        • 分用:运输层把收到的信息分别交付给上面应用层中相应的进程

      主要协议:TCP、UDP

    5. 网络层(最重要)
      主要任务是把分组从源端传到目的端,为分组交换网上的不同主机提供通信服务。

      网络层传输单位是数据报

      数据报过长时,会将数据报切割成一个个小的分组,再放到链路上传递

      • 功能1:路由选择(最佳路径)
      • 功能2:流量控制(协调发送端和接收端的速度)
      • 功能3:差错控制(奇偶校验等)
      • 功能4:拥塞控制
        若所有结点都来不及接收分组,而要丢弃大量分组的话,网络就处于拥塞状态。因此要采取一定措施缓解这种拥塞。

      主要协议:IP、IPX、ICMP、IGMP、ARP、RARP、OSPF

    6. 数据链路层

      主要任务是把网络层传下来的数据报组装成帧

      数据链路层/链路层的传输单位是

      • 功能1:成帧(定义帧的开始和结束)
      • 功能2:差错控制(帧错+位错)
      • 功能3:流量控制
      • 功能4:访问(接入)控制 控制对信道的访问

      主要协议:SDLC、HDLC、PPP、STP

    7. 物理层

      傻瓜层

      把比特流转成电信号的形式,然后放到链路上面进行传输,不需要对数据进行改动。
      主要任务是在物理媒体上实现比特流的透明传输,传输单位是比特
      透明传输:指不管所传数据是什么样的比特组合,都应当能够在链路层上传送。

      • 功能1:定义接口特性
      • 功能2:定义传输模式(单工、半双工、双工)
      • 功能3:定义传输速率
      • 功能4:比特同步
      • 功能5:比特编码

      主要协议:Rj45、802.3

脑图:

image-20210405234108810

1.2.3、 TCP/IP参考模型和5层参考模型

先有TCP/IP协议栈再有TCP/IP参考模型

image-20210405233009957

  1. OSI参考模型与TCP/IP参考模型相同点

    1. 都分层
    2. 基于独立的协议栈的概念
    3. 都可以实现异构网络互联
  2. OSI参考模型与TCP/IP参考模型的不同点

    1. OSI定义三点:服务、协议、接口

    2. OSI先出现,参考模型先于协议发明,不偏向特定协议

    3. TCP/IP设计之初就考虑到异构网互联问题,将IP作为重要的层次

    4. OSI VS TCP/IP

      OSI TCP/IP
      网络层 无连接+面向连接 无连接
      传输层 面向连接 无连接+面向连接

      面向连接:分为三个阶段

      1. 建立连接,发出一个建立连接的请求
      2. 连接成功之后,开始数据传输
      3. 数据传输完毕,释放连接

      无连接:直接进行数据传输

    5. 5层参考模型

      综合了OSI和TCP/IP的优点

      5层参考模型的分层及每层的功能:

      在这里插入图片描述

      5层参考模型的数据封装与解封装过程:

      在这里插入图片描述

1.3 第一章总结

在这里插入图片描述

第二章 物理层

在这里插入图片描述


第二章要学习的主要内容

  • 通信基础
  • 两个公式lim(考研重点)
  • 看图说话(数字信号的波形)
  • 传输介质
  • 物理层设备(中继器、集线器)

2.1.1、 物理层基本概念

  1. 物理层基本概念

    物理层解决如何在连接各种计算机的传输媒体上传输数据比特流,而不是指具体的传输媒体(传输媒体可以看做是第0层,要与物理层分开看)

    物理层的主要任务:确定与传输媒体接口有关的一些特性,定义标准

    物理层定义了哪些特性?

    • 机械特性
      定义物理连接的特性,规定物理连接时所采用的的规格、接口形状、引线数目引脚数量和排列情况
    • 电气特性
      规定传输二进制位时,线路上信号的电压范围、阻抗匹配、传输速率距离限制等。
    • 功能特性
      指明某条线上出现的某一电平表示何种意义,接口部件的信号线的用途
      比如:描述一个物理层接口引脚处于高电平时的含义。
    • 规程特性
      (过程特性)定义各条物理线路的工作规程和时序关系。

2.1.2 、数据通信基础知识

典型的数据通信模型:

在这里插入图片描述

  1. 数据通信相关术语

    通信的目的是传送消息

    • 数据:传送信息的实体,通常是有意义的符号序列。

    • 信号:数据的电气/电磁的表现,是数据在传输过程中的存在形式

      • 数字信号:代表消息的参数取值是离散
      • 模拟信号:代表消息的参数取值是连续
    • 信源:产生和发送数据的源头

    • 信宿:接收数据的终点

    • 信道:信号的传输媒介。一般用来表示向某一个方向传送信息的介质,因此一条通信线路往往包含一条发送信道和一条接收信道.

      信道分类

      • 传输信号分:数字信道(传送数字信号)、模拟信道(传送模拟信号)
      • 传输介质分:无线信道、有线信道
  2. 三种通信方式

    从通信双方信息的交互方式看,可以有三种基本方式

    • 单工通信
      只有一个方向的通信而没有反方向的交互,仅需要一条信道
    • 半双工通信
      通信的双方都可以发送或接收信息,但任何一方都不能同时发送和接收,需要两条信道
    • 全双工通信
      通信双方可以同时发送和接受信息,也需要两条信道
  3. 两种数据传输方式

    数据在信道上的传送方式

    • 串行传输
      速度慢,费用低、适合远距离
    • 并行传输
      速度快、费用高、适合近距离
      用于计算机内部数据传输(打印机,扫描机)

2.1.3 、码元、波特、速率、带宽

  1. 码元
    指用一个固定时长信号波形(数字脉冲),代表不同离散数值的基本波形,是数字通信中数字信号的计量单位,这个时长内的信号称为k进制码元,而该时长称为码元宽度。当码元的离散状态有M个时(M>2),此时码元为M进制码元。

    1码元可以携带多个比特的信息量。例如,在使用二进制编码时,只有两种不同的码元,一种代表0状态,另一种表示1状态。而四进制码元,一个码元可以携带2bit信息。(00/01/10/11)

    image-20210406001148097

  2. 速率、波特

    速率也叫数据率,是指数据的传输速率,表示单位时间内传输的数据量。可以用码元传输速率信息传输速率表示。

    1. 码元速率:(码元速率、波形速率、调制速率符号速率等等)
      它表示单位时间内数字通信系统所传输的码元个数(也可称为脉冲个数信号变化的次数),单位是波特(Baud)。1波特表示数字通信系统每秒传输一个码元。这里的码元可以是多进制的,也可以是二进制的,但是码元速率与进制数无关

      即:1s传输了多少码元

    2. 信息速率:
      表示单位时间内数字通信系统传输的二进制码元个数(即比特数)
      单位是比特/秒(b/s)

      即:1s传输多少比特

      关系:

      *若一个码元携带n bit的信息量,则M Buad的码元传输速率所对应的信息传输速率为Mn bit/s**

      即:*信息传输速率 = n bit * 码元传输速率*

      系统传输的是比特流,通常比较的的是信息传输速率

  3. 带宽
    表示在单位时间内从网络中的某一点到另一点所能通过的“最高数据率”,常用来表示网络的通信线路所能传输数据的能力。单位是b/s。

  4. 相关习题:

    image-20210406002432454

2.1.4、 奈氏准则和香农定理

  1. 失真:

    在这里插入图片描述

  2. 影响失真的因素:

    1. 码元传输速率(正相关)
      速率越快,信号失真程度越严重
    2. 信号传输距离(正相关)
      距离越远,衰减越久,干扰越久,对信号影响越大
    3. 噪声干扰(负相关)
      噪声越多,信号失真程度越大
    4. 传输媒体质量(负相关)
      传输媒体质量越差,越失真
  3. 失真的一种现象——码间串扰

    在这里插入图片描述

    信道带宽:信道能通过的最高频率和最低频率之差

    上图的信道带宽是:3300Hz-300Hz=3000Hz

    1. 速率过低为什么不能通过信道?

      速度太低,信号在信道上非常容易衰减

    2. 速度过高为什么不能通过信道?

      振动频率太快了,接收端在接收时区分不出来波形之间的差异(即:码间串扰)

      码间串扰:接收端收到的信好波形失去了码元之间清晰界限的现象

  4. 奈氏准则(奈奎斯特定理)

    在理想低通(无噪声,带宽受限)条件下,为了避免码间串扰,极限码元传输速率为2W Baud,W是信道带宽,单位是Hz在奈氏准则和香农定理中带宽的单位是Hz(不是bit/s)

    理想低通信道下的极限传输率=2Wlog2V(b/s)
    V:码元的种数/码元的离散电平数目

    根据奈氏准则可以得到以下4条结论:

    1. 在任何信道中,码元传输的速率是有上限的。若传输速率超过此上限,就会出现严重的码间串扰问题,使接收端对码元的完全正确识别成为不可能。
    2. 信道的频带越宽(即:能通过的信号高频分量越多),就可以用更高的速率进行码元的有效传输
    3. 奈氏准则给出了码元传输速率的限制,但并没有对信息传输速率给出限制
    4. 由于码元的传输速率受奈氏准则的制约,所以要提高数据的传输速率,就必须设法使每个码元能携带更多个比特的信息量,这就需要采用多元制的调制方法

    例:在无噪声的情况下,若某通信链路的带宽为3kHz,采用4个相位,每个相位具有4种振幅的QAM调制技术,则该通信链路的最大数据传输率是多少?

    分析:调相与调幅相结合

    解:

     信号有4 * 4=16种变化,则V=16;

    ​ 即:极限传输速率=2Wlog2(V)(b/s)= 2 * 3000 * log2(16)(b/s)= 24000(b/s)

  5. 香农定理

    噪声存在于所有的电子设备和通信系统中。由于噪声随机产生,它的瞬时值有时会很大,因此噪声会使接收端对码元的判决产生错误。但是噪声的影响是相对的,若信号较强,那么噪声影响相对较小。因此,信噪比就很重要。

    信噪比 = 信号的平均功率/噪声的平均功率,常记为S/N,并用分贝(dB)作为度量单位,即:
    信噪比(dB)=10 * log10(S/N)

    两者在数值上等价。

    而往往信噪比的这个值会很大,所以一般取对数。取了对数这个严格来说就叫做声强级,取对数实际上获得了次方的值,进而得到了声音(信息)的强度。但是两个东西表示的是同一信息。声强级是为了方便读数理解而对信噪比进行的变换(类似科学记数法)

    香农定理:

    带宽受限有噪声的信道中,为了不产生误差,信息的数据传输速率有上限值。

    信道的极限数据传输速率=W * log2(1+S/N) (b/s)

    S:信道所传信号的平均功率

    N:信道内的高斯噪声功率

    S/N即:信噪比

    W:带宽(Hz)

    image-20210406010014399

    根据香农定理可以得到以下5条结论:

    1. 信道的带宽或信道中的信噪比越大,则信息的极限传输速率就越高
    2. 对一定的传输带宽和一定的信噪比,信息传输速率的上限就确定了
    3. 只要信息的传输速率低于信道的极限传输速率,就一定能找到某种方法实现无差错传输
    4. 香农定理得出的为极限信息传输速率,实际信道能达到的传输速率要比它低不少
    5. 从香农定理可以看出,若信道带宽W或信噪比S/N没有上限(不可能),那么信道的极限信息传输速率也就没有上限。

    例题:

    image-20210406010235606

  6. 奈氏准则和香农定理的联系与区别:

    奈氏准则(内忧,码间串扰)香农定理(外患,外界噪声)
    有时候既需要使用奈氏准则,也需要使用香农定理,这时,需要取两者中的最小值

    image-20210406010414253

    例:二进制信号在信噪比为127∶1的4kHz信道上传输,最大的数据速率可达到多少?
    解:

    由奈氏准则知:最大数据传输速率为=2 * W * log2(V)
                              =2 * 4000 * 1=8000(b/s)
    有香农定理知:最大数据传输速率为=W * log2(1+S/N)
                              =4000 * log2(1+127)
                              =28000(b/s)
    最大的数据传输率为8000(b/s)

2.1.5 、编码与调制

  1. 基带信号与宽带信号

    信道上传送的信号

    1. 基带信号:将数字信号1和0直接用两种不同的电压表示,再送到数字信道上去传输(基带传输
      基带信号是来自信源的信号,就像计算机输出的代表各种文字或者图像文件的数据信号都属于基带信号。
      基带信号就是发出的直接表达了要传输的信息的信号,比如我们说话的声波就是基带信号。
    2. 宽带信号:将基带信号进行调制后形成的频分复用模拟信号,再传到模拟信道上去传输(宽带传输
      把基带信号经过载波调制后,把信号的频率范围搬迁到较高的频段一遍在信道中传输(即:仅在一段频率范围内能够通过信道)
      在传输距离较时,计算机网络采用基带传输方式(近距离衰减小,从而信号内容不易发生变化)
      在传输距离较时,计算机网络采用宽带传输方式(近距离衰减大,即使信号变化大也能最后过滤出来基带信号)

    image-20210407105303305

  2. 编码与调制

    数据–>数字信号 编码

    数据–>模拟信号 调制

    数字数据–数字发送器–>数字信号 编码

    数字数据–调制器–>模拟信号 调制

    模拟数据–PCM编码器–>数字信号 编码

    模拟数据–放大器调制器–>模拟信号 调制

  3. 四种编码与调制方法

    1. 数字数据编码为数字信号
    2. 数字数据调制为模拟信号
    3. 模拟数据编码为数字信号
    4. 模拟数据调制为模拟信号
  4. 数字数据编码为数字信号

    1. 非归零编码【NRZ】

      编码方式:高1低0

      编码容易实现,但没有检错功能,且无法判断一个码元的开始和结束,以至于收发双方难以保持同步。

      发送端全1或0,接收端都不好识别,需要确定时间周期

      不常用

    2. 曼彻斯特编码

      综合归零编码、非归零编码、反向不归零编码的优缺点而形成的非常优秀的编码

      它可以把时钟信号和数据都放在一块,不需要额外的信道传输时钟信号,就可以实现自己本身的同步,即:自同步
      编码方式:

      • 将一个码元分成两个相同的间隔,前一个间隔为低电平后一个为高电平表示码元1;
      • 码元0则正好相反。也可以采用相反的规定。

      该编码的特点是:在每一个码元的中间出现电平跳变,位于中间的跳变既作为时钟信号(用于同步),
      又作为数据信号,但它所占的频带宽度是原始的基带宽度的两倍。每一个码元都被调成两个电平,所以数据传输速率只有调制速率的1/2。

    3. 差分曼彻斯特编码(常用于局域网传输)

      编码方式:同1异0

      其规则是:若码元为1,则前半个码元的电平与上一个码元的后半个码元的电平相同,若为0,则相反。

      该编码的特点是:
      在每个码元的中间,都有一次电平的跳转,可以实现自同步,且抗干扰性强于曼彻斯特编码(因为实现算法更复杂)

    4. 归零编码【RZ】

      编码方式:信号电平在一个码元之内都要回复到零的方式

      全零不容易识别

      不常用

    5. 反向不归零编码【NRZI】

      编码方式:信号电平翻转表示0,信号电平不变表示1

      发送端全0,接收端容易识别,发送端全1,接收端不好识别

    6. 4B/5B编码

      比特流中插入额外的比特以打破一连串的0或1,就是用5个比特来编码4个比特的数据,之后再传给接收方,因此称为4B/5B。编码效率为80%

      只采用16种5位码对应16种不同的4位码,其他的16种5位码作为控制码(帧的开始或结束,线路的状态信息等)或保留。

      image-20210407110138680

    前三种重点掌握,后三种了解即可

  5. 数字数据调制为模拟信号

    数据调制技术:
    在发送端将数字信号转化为模拟信号,而在接收端将模拟信号还原为数字信号,分别对应于调制解调器的调制和解调过程。

    • 2ASK 调幅
      低电平0没有幅动,高电平1有幅动
    • 2FSK 调频
      低电平0低频,高电平1高频
    • 2PSK 调相
      0对应一种波形,1对应一种波形
    • QAM 调幅+调相
      例如:某通信链路的波特率是1200Baud,采用4个相位,每个相位有4种振幅的QAM调制技术,则该链路的信息传输速率是多少?
      解:
      信号有4 * 4 = 16种变化
      信息传输速率 = W * log2(V)= 1200 * 4 = 4800(b/s)

    在这里插入图片描述

  6. 模拟数据编码为数字信号

    1. 计算机内部处理的是二进制数据,处理的都是数字音频,所以需要将模拟音频通过采样、量化转换成有限个数字表示的离散序列(即实现音频数字化)。

    2. 最典型的例子就是对音频信号进行编码的脉码调制(PCM脉码调制),在计算机应用中,能够达到最高保真水平的就是PCM编码,被广泛用于素材保存及音乐欣赏,CD、DVD以及我们常见的WAV文件中均有应用。

      它主要包括三步:抽样、量化、编码。

      1. 抽样

        对模拟信号周期性扫描,把时间上连续的信号变成离散的信号。

        为了使所得的离散信号能够无失真地代表被抽样的模拟数据,要使用采样定理进行采样。

        采样定理:(奈奎斯特采样定理)
        f采样频率 >= 2 * f信号最高频率
        (最高分波形上至少采样两个点)

      2. 量化

        把抽样取得的电平幅值按照一定的分级标度转化为对应的数字值,并取整数,这就把连续的电平幅值转换为离散的数字量。

      3. 编码

        把量化的结果转换为与之对应的二进制编码

        image-20210407110815400

  7. 模拟数据调制为模拟信号
    为了实现传输的有效性,可能需要较高的频率。这种调制方式还可以使用频分复用技术,充分利用带宽资源。在电话机和本地交换机所传输的信号是采用模拟信号传输模拟数据的方式;模拟的声音数据是加载到模拟的载波信号中传输的。

脑图:

在这里插入图片描述

2.2.1、 物理层传输介质

  1. 传输介质及分类

    1. 传输介质也称传输媒体/传输媒介,它就是数据传输系统中在发送设备和接收设备之间的物理通路

    2. 传输媒体并不是物理层

      传输媒体在物理层的下面,因为物理层是体系结构的第一层,因此有时称传输媒体为0层。在传输媒体中传输的是信号,但传输媒体并不知道所传输的信号代表什么意思。但物理层规定了电气特性,因此能够识别所传送的比特流。

    3. 如果称物理层是傻瓜,那么传输媒体连傻瓜都不如

    4. 传输媒体分类

      1. 导向性传输媒体

        电磁波被导向沿着固体媒体(铜线/光纤)传播

      2. 非导向性传输媒体

        自由空间,介质可以是空气、真空、海水等。

  2. 导向性传输介质

    1. 双绞线:是古老、又最常用的传输介质,它由两根采用一定规则并排绞合的、相互绝缘的铜导线组成。

      绞合可以减少相邻导线的电磁干扰。

      为了进一步提高抗电磁干扰能力,可在双绞线的外面再加上一个由金属丝编织成的屏蔽层,这就是屏蔽双绞线(STP)

      无屏蔽层的双绞线就称为非屏蔽双绞线(UTP)。

      image-20210409094235649

      特点:

      • 双绞线价格便宜,是最常用的传输介质之一,在局域网和传统电话网中普遍使用。
      • 模拟传输和数字传输都可以使用双绞线,其通信距离一般为几公里到数十公里。
      • 距离太远时,对于模拟传输,要用放大器放大衰减的信号;
      • 对于数字传输,要用中继器将失真的信号整形
    2. 同轴电缆

      同轴电缆由导体铜质芯线绝缘层网状编织屏蔽层塑料外层构成。

      按特性阻抗数值的不同,通常将同轴电缆分为两类:50欧姆同轴电缆和75欧姆同轴电缆。

      其中,50欧姆同轴电缆主要用于传送基带数字信号,又称为基带同轴电缆,它在局域网中得到广泛应用;75欧姆同轴电缆主要用于传送宽带信号,又称为宽带同轴电缆,它主要用于有线电视系统。

      image-20210409094427395

      双绞线和同轴电缆的区别

      由于外导体屏蔽层的作用,同轴电缆抗干扰特性比双绞线,被广泛用于传输较高速率的数据,其传输距离更远,但价格较双绞线

    3. 光纤

      1. 光纤通信就是利用光导纤维(简称光纤)传递光脉冲来进行通信。有光脉冲表示1,无光脉冲表示O。

        而可见光的频率大约是10^8MHz,因此光纤通信系统的带宽远远大于目前其他各种传输媒体的带宽。

      2. 光纤在发送端有光源,可以采用发光二极管或半导体激光器,它们在电脉冲作用下能产生出光脉冲,在接收端用光电二极管做成光检测器,在检测到光脉冲时可还原出电脉冲。

      3. 光纤主要由纤芯(实心的!)和包层构成,光波通过纤芯进行传导,包层较纤芯有较低的折射率。当光线从高折射率的介质射向低折射率的介质时,其折射角将大于入射角。因此,如果入射角足够大,就会出现全反射,即光线碰到包层时候就会折射回纤芯、这个过程不断重复,光也就沿着光纤传输下去。

        超低损耗,传送超远距离

        image-20210409095419124

      4. 分类

        1. 多模光纤

          传播过程会有损耗,传播过程中会受到噪声的影响,如果距离过远可能会出现较为严重的失真,适合近距离传输

        2. 单模光纤

          单模与多模光纤的比较一根光缆少则只有一根光纤,多则包括十至数百根光纤

          在这里插入图片描述

      5. 光纤的特点

        1. 传输损耗小,中继距离长,对远距离传输特别经济
        2. 抗雷电和电磁干扰性能好
        3. 无串音干扰,保密性好,也不易被窃听或截取数据
        4. 体积小,重量轻
  3. 非导向性传输介质

    1. 无线电波

      信号向所有方向传播

      较强穿透能力,可传远距离,广泛用于通信领域(手机通信)

    2. 微波

      信号固定方向传播

      微波通信频率较高、频段范围宽,因此数据率很高

      1. 地面微波接力通信

        中继站

      2. 卫星通信

        同步卫星起到了中继站的作用

        • 优点
          1. 通信容量大
          2. 距离远
          3. 覆盖广
          4. 广播通信和多址通信
        • 缺点
          1. 传播时延长(250-270ms)
          2. 受气候影响大(eg:强风、太阳黑子爆发)
          3. 误码率较高
          4. 成本高
    3. 红外线、激光

      信号固定方向传播

      把要传输的信号分别转换为各自的信号格式,即红外光信号和激光信号,再在空间中传播。
      (微波不需要转换格式)

脑图:

在这里插入图片描述

2.2.2 、物理层设备

  1. 中继器

    1. 诞生的背景:由于存在损耗,在线路上传输的信号功率会逐渐衰减,衰减到一定程度时将会造成信号失真。

    2. 中继器的功能:对信号进行再生和还原,对衰减的信号进行放大,保持与原数据相同,以增加信号传输的距离,延长网络的长度。

简而言之:再生数字信号
3. 中继器的两端:

  1. 两端的网络部分是**网段**,而不是子网,适用于**完全相同的两类网络的互连**,且**两个网段速率要相同**。

  2. 中继器只将任何电缆段上的数据发送到另一段电缆上,它仅作用于信号的电气部分,并不管数据中是否有错误或不适于网段的数据。

  3. **两端可连相同的媒体,也可连不通的媒体**。

  4. 中继器两端的网段一定要是**同一个协议**。(**中继器不会存储转发**)
  1. 5-4-3规则:网络标准中都对信号的延迟范围作了具体的规定,因而中继器只能在规定的范围内进行,否则会网络故障。

    5个网段,4个网络设备,3个段可以连接计算机

    image-20210409164354784

  2. 集线器(多口中继器)

    1. 再生,放大信号

    2. 集线器的功能:对信号进行再生放大转发,对衰减的信号进行放大,接着转发到其他所有(除输入端口外)处于工作状态的端口上,以增加信号传输的距离,延长网络的长度。不具备信号的定向传送能力,是一个共享式设备

    3. 星型拓扑(广播通信)

      image-20210409165035181

    4. 集线器不能分割冲突域。当有超过两台主机同时发送数据给集线器就会发生信息碰撞,要等待随机一段时间之后在发送数据。当集线器连接的主机数目越来越多的时候,由于产生信息碰撞的概念变大,集线器的工作效率也会降低。连在集线器上的工作主机平分带宽。

2.3 、第二章总结

在这里插入图片描述

第三章 数据链路层

在这里插入图片描述


第三章学习的主要内容是:

  1. 链路层的功能
  2. 链路层的两种信道
  3. 局域网、广域网
  4. 链路层的设备

3.1.1、 数据链路层功能概述

  1. 数据链路层的研究思想:

    想象数据是直接从发送端的数据链路层,经过中间系统水平发送到接收端的数据链路层

  2. 数据链路层的基本概念

    1. 结点:主机、路由器

    2. 链路:网络中两个结点之间的物理通道,链路的传输介质主要有双绞线、光纤和微波。分为有线链路、无线链路。

    3. 数据链路:网络中两点之间的逻辑通道,把实际控制数据传输协议的硬件和软件加到链路上就构成了数据链路。

    4. 帧:链路层的协议数据单元,封装网络层的数据报

    数据链路层负责通过一条链路从一个节点向另一个物理链路直接相邻的相邻结点传送数据报。

  3. 数据链路层功能概述

    在物理层提供服务的基础之上向网络层提供服务,其最基本的服务是将源自网络层来的数据可靠地传输到相邻结点的目标机网络层。

    其主要作用是加强物理层传输原始比特流的功能,将物理层提供的可能出错的物理连接改造成逻辑上无差错的数据链路,使之对网络层表现为一条无差错的链路。

    • 功能1:为网络层提供服务。无确认连接服务有确认无连接服务有确认面向连接服务。(有连接一定有确认!)
      • 无确认连接服务:通常用于实时通信或者误码率比较低的通信信道。源主机在发送数据的时候不用事先与目的主机建立好链路的连接,而且目的主机收到数据帧的时候也不用返回确认。如果数据帧丢失了数据链路层也不负责重发,而直接交给上一层处理。(不负责但很快)
      • 有确认无连接服务:通常用于无线通信或者误码率比较高的通信信道。源主机在发送数据的时候不用事先与目的主机建立好链路的连接,但是目的主机收到数据帧的时候需要向源主机返回确认。如果源主机发现在规定时间内没有收到目的主机发送的确认信号,它就把刚才没有收到确认的这个数据帧重新发送,以此来提高数据链路层的可靠性。
      • 有确认面向连接服务:源主机在发送数据的时事先与目的主机建立好链路的连接,目的主机收到数据帧的时也向源主机返回确认。源主机发现确认信号才能发送下一个。(最安全最可靠但速度也是最慢的)
    • 功能2:链路管理,即连接的建立、维持、释放(用于面向连接的服务)
    • 功能3:组帧
    • 功能4:流量控制,限制发送方
    • 功能5:差错控制(帧错/位错)

3.1.2、 封装成帧和透明传输

  1. 封装成帧

    1. 概念:

      就是在一段数据的前后部分添加首部和尾部,这样就构成了一个帧。

      接收端在收到物理层上交的比特流后,就能根据首部和尾部的标记,从收到的比特流中识别帧的开始和结束。

      首部和尾部包含许多的控制信息,他们的一个重要的作用:帧定界(确定帧的界限)

    2. 帧同步:接收方应当能从接收到的二进制比特流中区分出帧的起始和终止。

    3. 组帧的四种方法

      1. 字符计数法
      2. 字符(节)填充法
      3. 零比特填充法
      4. 违规编码法
    4. 示意图

      在这里插入图片描述

  2. 透明传输

    指:不管所传数据是什么样的比特组合,都应当能够在链路上传送。

    因此,链路层就“看不见”有什么妨碍数据传输的东西。当所传数据中的比特组合恰巧与某一个控制信息完全一样时,必须采取适当的措施,使接收方不会将这样的数据错误认为是某种控制信息,这样才能保证数据链路层的传输是透明的。

  3. 字符计数法(不常用)

    帧首部使用一个计数字段(第一个字节,8位)来表明帧内字符数(字节数)。

    image-20210409182821894

    痛点:鸡蛋装在一个篮子里。

    如果第一个字节(计数字段)是错误的,则后面的帧全部发生错误。这样接收方没有办法正确接收每一个帧。

  4. 字符填充法

    当传送的帧是由文本文件组成时(文本文件的字符都是从键盘输入的,都是ASCII码),不管从键盘上输入什么字符都可以放在帧里面传过去,即透明传输

    image-20210409182945627

    当传送的帧是由非ASCII码的文本文件组成时(二进制代码的程序或图像等),就采用字符填充法实现透明传输。

    image-20210409183020592

    字符填充法的示意图:

    在这里插入图片描述

  5. 零比特填充法

    image-20210409192047830

    操作

  6. 在发送端,扫描整个信息字段(原始数据),只要连续5个1,就立即填入1个0

    即:5”1”,1”0”

  7. 在信息字段前后都加上0111110,作为帧的边界

  8. 在接收端收到一个帧时,先找到标志字段确定边界,再用硬件对比特流进行扫描。

    发现连续5个1时,就把后面的0删除。

    保证了透明传输:在传送的比特流中可以传送任意比特组合,而不会引起对帧边界的判断错误。

  9. 违规编码法

    对于曼彻斯特编码,可以使用高-高,低-低来定帧的起始和终止。

    局域网的IEE802标准就采用了该方法。

总结:

由于字节计数法中Count字段(第一个字节)的脆弱性(其值若有差错将导致灾难性后果)及字符填充实现上的复杂性和不兼容性,目前较普遍使用的帧同步法是比特填充违规编码法

3.1.3 、差错控制(比特错,检错编码,纠错编码)

  1. 差错从何而来?

    概括来说,传输中的差错都是由于噪声引起的。

    1. 全局性:由于线路本身电气特性所产生的的随机噪声(热噪声),是信道固有的,随机存在的。

      解决办法:提高信噪比来减少或避免干扰。(对传感器下手

    2. 局部性:外界特定的短暂原因所造成的冲击噪声,产生差错的主要原因。

      解决办法:通常利用编码技术来解决。

  2. 差错的分类

    1. 位错:比特位出错,1变成0,0变成1

    2. 帧错:分为三种:丢失,重复,失序

      例如:要传输三个帧[#1]-[#2]-[#3],则:

      • 帧丢失:[#1]-[#3]
      • 帧重复:[#1]-[#2]-[#2]-[#3]
      • 帧失序:[#3]-[#2]-[#1]

      针对这些帧错误,会采用帧编号、确认重传机制等来进行帧的差错控制。

      image-20210409195542838

      这是过去OSI模型的观点,现在通信链路的质量大大提高,因为通信链路质量不好引起的差错概率越来越小。

      现在的因特网会采用较为灵活的方法,针对不同的网络,我们会选择是否采用确认重传机制。

  3. 链路层为网络层提供的服务:

    1. 无确认无连接服务
    2. 有确认无连接服务
    3. 有确认面向连接服务

    若通信质量好,比如有线传输链路,链路层协议就不会采用确认和重传机制,而且也不要求链路层向网络层提供有效可靠传输的服务(即只有无确认无连接服务),如果发生差错,改错任务会交给上层协议(传输层)。

    若通信质量差,比如无线传输链路,链路层协议就会采用确认和重传机制数据链路层就需要向上提供可靠传输的服务(即需要提供有确认无连接服务和有确认面向连接服务)

  4. 数据链路层的差错控制(比特错,帧错会在后面讲解)

    差错控制:

    1. 检错编码
      1. 奇偶校验码
      2. 循环冗余码CRC
    2. 纠错编码
      1. 海明码
    3. 数据链路层编码和物理层编码的区别
      • 数据链路层编码和物理层的数据编码与调制不同
      • 物理层编码针对的是单个比特,解决传输过程中比特的同步等问题,如曼彻斯特编码。
      • 数据链路层的编码针对的是一组比特,它通过冗余码的技术实现一组二进制比特串在传输过程中是否出现了差错。
  5. 奇偶校验码

    ​ n-1位信息元,1位校验元

    1. 奇校验码

      信息元和校验元中,“1”的个数为奇数

    2. 偶校验码

      信息元和校验元中,“1”的个数为偶数

    奇偶校验码特点:只能检查出奇数个比特错误,检错能力为50%

  6. CRC循环冗余码

    1. 发送端最终发送的数据:要发送的数据+帧检验序列FCS

      计算冗余码(FCS帧检验序列)

      1. 第1步:加0 假设生成多项式G(x)的阶为r,则加r个0(多项式是n位,则阶是n-1位)

        加0是为了不改变原发送数据,FSC帧检验直接跟在原发送数据的后面即可

      2. 第2步:模2除法。数据加0后除以多项式,余数就是冗余码FCS。

        在除法过程中应该做减法的步骤,在模2除法中替换为异或

    2. 接收端检错过程

      把接收的每一帧都除以相同的除数(发送端的生成多项式),然后检查得到的除数R

      • 若R==0,判定这个帧没有差错,接受
      • 若R!=0,判断这个帧有差错(无法确认到位),丢弃
    3. FCS的生成以及接收端CRC检验都是由硬件实现,处理很迅速,因此不会延误数据的传输。

    4. 在数据链路层仅仅使用循环冗余检验CRC差错检测技术,只能做到对帧的无比特差错接收,即“凡是接收端数据链路层接受的帧,我们都能以非常接近于1的概率认为这些帧在传输过程中没有产生差错”。

      可以认为:“凡是接收端数据链路层接收的帧均无差错”

      接收端丢弃的帧虽然曾收到了,但是最终还是因为有差错被丢弃。

    5. 但是帧的无差错接受不是可靠传输,CRC循环冗余码只能检验出帧有错误并丢弃,但是不能对错误的帧进行校正。

      可靠传输是指:数据链路层发送端发送什么,接收端就收到什么。

  7. 海明码

    1. 发现双比特错,纠正单比特错

    2. 工作原理:牵一发而动全身

    3. 工作流程

      1. 确定校验码位数r

        海明不等式:2^r >= k+r+1 (r是冗余信息位,k为信息位)

      2. 确认校验码和数据的位置

        校验码只能填在2的n次方的位置(包括第一个位置)

        原码按顺序插入

      3. 求出校验码的值

        首先将数据位从低位到高位按1,2,3…进行编号,然后将编号用二进制表示,记录二进制表示中的第n位为1的数据位,令这些数据位上的上的数据异或=0,则Pn即为所求这些数据位上包括Pn,则公式可以改进为:Pn=这些数据位除了Pn之外异或(原理:相同异或为零)

        补充:异或的性质

        1. 任意二进制数与0异或之后是本身

        2. 任意二进制数与1异或之后是取反

        3. 偶数个1异或是0(2 * k个1,即在1的基础上进行2 * k-1次取反操作),

          奇数个1异或是1(2 * k+1个1,即在1的基础上进行2 * k次取反操作),

          无论0有多少个

      4. 检错并纠错

        记录二进制表示中的第n位为1的数据位,求这些数据位上的上的数据异或,则Jn即为所求
        J=JN…J1J2将J转换为十进制JT,即第JT位发生错误

    4. 比如:D=101101

      1. 第1步:先求出校验码的位数:2^4=16>=4+6+1,所以校验码为4位

      2. 第2步:按位数分别给校验码、原码编号,原码一共有6位,即编号为:D6 D5 D4 D3 D2 D1

        校验码一共有4位,即编号为P4 P3 P2 P1

        校验码和原码的位置分配:D6 D5 P4 D4 D3 D2 P3 D1 P2 P1

        ​ 位置编号 10 9 8 7 6 5 4 3 2 1
        ​ 位置二进制编码 1010 1001 1000 0111 0110 0101 0100 0011 0010 0001

      3. 第3步:求P1,D5 D4 D2 D1 P1的位置编码的第一位是1,所以令D5 D4 D2 D1 P1的异或=0,求出P1

        Pn的求法同理,先把能求的求出来,最后把之前不能求的再求出来

        求得:P1=P2=P3=0,P4=1

      4. 第4步:求得海明码1011100100

脑图:

在这里插入图片描述

3.1.4 、流量控制与可靠传输机制

  1. 数据链路层的流量控制

    1. 较高的发送速度较低的接收能力的不匹配,会造成传输出错,因此流量控制也是数据链路层的一项重要工作
    2. 流量控制在传输层也有
    3. 链路层与传输层流量控制的区别:
      1. 数据链路层的流量控制是点对点的,而传输层的流量控制是端到端的。
      2. 数据链路层流量控制的手段:接收方收不下就不回复确认
      3. 传输层流量控制手段:接收端给发送端一个窗口公告
  2. 流量控制的方法

    1. 停止等待协议(也可以算是一个特殊的滑动窗口协议,这种协议内发送和接收窗口都是1)

      每发送完一个帧就停止发送,等待对方的确认,在收到确认后再发送下一个帧。

      效率低

      发送窗口大小=1,接收窗口大小=1;窗口大小固定

      在这里插入图片描述

    2. 滑动窗口协议

      1. 后退N帧协议(GBN)

        发送窗口大小>1,接收窗口大小=1;窗口大小固定

      2. 选择重传协议(SR)

        发送窗口大小>1,接收窗口大小>1;窗口大小固定

  3. 可靠传输、滑动窗口、流量控制概念解析

    1. 可靠传输:发送端发啥,接收端收啥
    2. 流量控制:控制发送速率,使接收方有足够的换种空间来接收每一帧。
    3. 滑动窗口是解决流量控制(收不下就不给确认,想发也发不了)和可靠传输(发送方自动重传)的方式

3.1.4.1、 停止-等待协议

  1. 停止-等待协议究竟是哪一层?

    在早期的计算机网络中,由于通信链路质量差,出现差错比较多,为了提高传输效率,数据链路层应该承担一部分可靠传输的任务,把停止-等待协议放在了数据链路层。

    在现在的计算机网络中,通信链路质量大大提高,出现差错的情况很少,不用承担可靠传输的任务,提高了通信速度,降低了延迟。

    停止-等待协议放在了传输层链路层则主要负责差错控制

  2. 为什么要有停止-等待协议?

    1. 除了比特出差错,底层信道还会出现丢包问题

      丢包:物理线路故障、设备故障、病毒攻击、路由信息错误等原因会导致数据包的丢失
      (数据包其实就是一个数据,在数据链路层叫帧,在网络层就叫IP数据报或者分组,在传输层也可以叫报文段

    2. 为了解决丢包问题(可靠控制)和流量控制就出现了停止-等待协议

  3. 研究停止等待协议的前提?

    1. 虽然现在常用全双工通信方式,但为了讨论问题方便,仅考虑一方发送数据(发送方),一方接收数据(接收方)
    2. 因为是在讨论可靠传输的原理,所以并不考虑数据是在哪一层次上传送的
    3. “停止-等待”就是每发送完一个分组就停止发送没等待对方确认,在收到确认后再发送下一个分组。
  4. 停止等待协议有几种应用情况?

    1. 无差错情况

      image-20210409211423472

    2. 有差错情况

      1. 数据帧丢失和检测到帧出错

        1. 超时计时器:每发送一个帧就启动一个计时器

        2. 如果在计时器到期之前收到了确认帧,则计时器终止。

          如果计时器到期了还没有收到确认帧,则发送方会重新发送没收到确认帧的数据帧

        3. 超时计时器设置的重传时间应当比帧传输的平均RTT(往返传播时延)更长一些

        4. 注意事项

          1. 发送完一个帧后,必须保留它的副本。
          2. 数据帧和确认帧必须编号

        image-20210409211646062

      2. ACK丢失(确认帧丢失)

        发送方超时计时器到期后没有收到确认帧,发送方重传数据帧

        接收方收到了重复的数据帧,丢弃重复的数据帧,并重传确认帧

        image-20210409211747811

      3. ACK迟到(确认帧迟到)

        超时还没收到确认帧则重传数据帧,接收方收到了重复的数据帧,并丢弃重复的数据帧

        发送方之后在等待另一个确认帧时,收到了迟到的确认帧,会不对迟到的数据帧做处理

        image-20210409211901849

  5. 停止-等待协议性能分析

    1. 优点:简单

    2. 缺点:信道利用率太低

      image-20210409212059380

      信道利用率:发送方在一个发送周期内,有效地发送数据所需要的时间占整个发送周期的比率信道利用率 = (L/C)/ T

      • L:T内发送L比特数据
      • C:发送方数据传输率
      • T:发送周期,从发送数据开始,到收到第一个确认帧为止(一般包括发送时间和RTT,接收数据帧的时间可以忽略)

      信道吞吐率 = 信道利用率 * 发送方的发送速率

    例题:

    image-20210409212300872

脑图:

image-20210409212332589

3.1.4.2 、后退N帧协议(GBN)

停止等待协议的弊端:信道利用率太低,太闲了。

采用流水线技术对停止-等待协议(一个数据帧跟着数据帧发送)进行改进。

使用流水线技术后:

  1. 必须增加数据帧序号的范围
  2. 发送方需要缓存多个分组

所以出现了后退N帧协议(GBN)选择重传协议(SR)

  1. 后退N帧协议中的滑动窗口

    发送窗口:发送方维持一组连续的允许发送的帧的序号

    接收窗口:接收方维持一组连续的允许接收帧的序号。

    ​ 在后退N帧协议中,接收窗口只有一个

    ​ 在选择重传协议中,接收窗口有多个

    image-20210409213121135

  2. 后退N帧协议执行过程

    1. GBN发送方必须响应的三件事

      1. 上层(网络层)的调用

        上层要发送数据时,发送方先检查发送窗口是否已满,如果未满,则产生一个帧并将其发送;

        如果窗口已满,发送方只需将数据返回给上层,暗示上层窗口已满。上层等一会再发送(实际实现中,发送方可以缓存这些数据,窗口不满时再发送帧。)

      2. 收到了一个ACK

        GBN协议中,对n号帧的确认采用累计确认的方式,标明接收方已经收到n号帧和它之前的全部帧。

        累计确认:例如:接收方返回了一个对于3号帧的确认帧,而数据帧的编号也是从0号开始的(0/1/2/3/4/….)。如果接收方将一个3号帧对应的确认帧给发送方。发送方就知道接收方已经接收到3号帧以及3号帧以前的所有的帧(0/1/2帧)。也就是说0到3号帧接收方已经完全接收了。这就是累积确认方式。

        也就是说,在GBN协议当中,接收方不用对于每一个数据帧都逐个返回一个对应的确认帧。他可以隔一会在发送一个确认帧。它这个确认帧就是想告诉发送方:包括这个帧,以及这个帧以前的所有帧,它都已经全部正确接收了。

      3. 超时事件

        协议的名字为后退N帧/回退N帧,来源于出现丢失和时延过长帧时发送方的行为。就像在停等协议中一样,定时器将再次用于恢复数据帧或确认帧的丢失。如果出现超时,发送方重传所有已发送但未被确认的帧。

    2. GBN接收方要做的事

      如果正确收到n号帧,并且按序,那么接收方为n帧发送一个ACK,并将该帧中的数据部分交付给上层。

      其余情况都丢弃帧,并为最近按序接收的帧重新发送ACK。接收方无需缓存任何失序帧,只需要维护一个信息: expectedseqnum(下一个按序接收的帧序号)。

      即:接收方很专一,如果没有接收到对应帧的到来,后面的帧即使到了也会被丢弃

    3. 示意图

      在这里插入图片描述

  3. 滑动窗口长度可以无限长吗?

    若采用n个比特对帧编号,那么发送窗口的尺寸WT,应满足:1 <= W <= 2^n-1。因为发送窗口尺寸过大,就会使得接收方无法区别新帧和旧帧(新帧与旧帧的帧编号相同)。

  4. GBN协议重点总结

    1. 累计确认(偶尔捎带确认,接收方把确认帧放在了接收方要发给发送方的数据里)
    2. 接收方只按顺序接收帧,不按序无情丢弃
    3. 确认序列号最大的,按序到达的帧
    4. 发送窗口最大为2^n-1,接收窗口大小为1

    例题:

    image-20210409215055723

  5. GBN协议性能分析

    1. 优点:因连续发送数据帧而提高了信道利用率

    2. 缺点:在重传时,必须把原来已经正确传送的数据帧重传,使得传送效率降低

      选择重传协议可以解决这个缺点

脑图:

image-20210409215131878

3.1.4.3 、选择重传协议(SR)

GBN协议的弊端:累计确认—>批量重传。

可不可以只重传出错的帧?

解决办法:设置单个确认,同时加大接收窗口,设置接收缓存,缓存乱序到达的帧。

  1. 选择重传协议中的滑动窗口示意图

    在这里插入图片描述

  2. SR发送方必须响应的三件事

    1. 上层的调用

      从上层收到数据后,SR发送方检查下一个可用于该帧的序号,如果序号位于发送窗口内,则发送数据帧;

      否则就像GBN一样,要么将数据缓存,要么返回给上层之后再传输。

    2. 收到了一个ACK

      如果收到ACK,加入该帧序号在窗口内,则SR发送方将那个被确认的帧标记为已接收。

      如果该帧序号是窗口的下界(最左边第一个窗口对应的序号),则窗口向前移动到具有最小序号的未确认帧处。

      如果窗口移动了并且有序号在窗白内的未发送帧,则发送这些帧。

    3. 超时事件

      每一个帧都有自己的定时器,一个超时事件发生后只重传一个帧

      哪个帧的超时器超时,则重传哪个帧

  3. SR接收方要做的事情

    1. 窗口内的帧来者不拒

    2. SR接收方将确认一个正确接收的帧不管其是否按序

      失序的帧将被缓存,并返回给发送方一个该帧的确认帧【收谁就确认谁】,直到失序前面所有帧(即序号更小的帧)皆被接收到为止,这时才可以将一批帧按序交付给上层,然后向前滑动窗口

    3. 如果收到了窗口序号外(小于窗口下界)的帧,就返回一个ACK

    4. 其他情况就忽略该帧

  4. SR协议运行过程示意图

    在这里插入图片描述

  5. 滑动窗口长度可以无限长吗?

    1. 发送窗口大小最好等于接收窗口(大了会溢出,小了没意义)

      image-20210409223428612

    2. WTmax=WRmax=2^(n-1)

      image-20210409223645651

  6. SR协议重点总结

    1. 对数据帧逐一确认,收一个确认一个
    2. 只重传出错帧
    3. 接收方有缓存
    4. WTmax=WRmax=2^(n-1)

例题:

image-20210409223859479

脑图:

image-20210409223919103

3.2.1 、信道划分介质访问控制

  1. 传输数据使用的两种链路

    1. 点对点链路

      两个相邻节点通过一个链路相连,没有第三者。

      应用:PPP协议常用于广域网

    2. 广播式链路

      所有主机共享通信介质。

      应用:早期的总线以太网、无线局域网,常用于局域网

      典型拓扑结构:总线型、星型(逻辑总线型)

  2. 介质访问控制

    介质访问控制的内容就是,采取一定的措施,使得两对界限之间的通信不会发生相互干扰的情况

    介质访问控制分类

    1. 静态划分信道,即信道划分介质访问控制(C!WTF)

      1. 频分多路复用FDM(frequency)
      2. 时分多路复用TDM(time)
      3. 波分多路复用WDM(wave)
      4. 码分多路复用CDM(code)
    2. 动态分配信道

      1. 轮询访问介质访问控制

        令牌传递协议

      2. 随机访问介质访问控制

        1. ALOHA协议
        2. CSMA协议
        3. CSMA/CD协议(重要)
        4. CSMA/CA协议(重要)
  3. 信道划分介质访问控制

    将使用介质的每个设备与来自同一信道上的其他设备的通信隔离开,把时域和频域资源合理地分配给网络上的设备

    1. 多路复用技术

      把多个信号组合放在一条物理信道上进行传输,使得多个计算机或终端设备共享信道资源,提高信道利用率

      把一条广播信道,逻辑上分成几条用于两个节点之间通信的互不干扰的子信道,实际就是把广播信道转变为点对点信道

      图示:

      在这里插入图片描述

    2. 静态划分信道(信道划分介质访问控制)

      1. 频分多路复用FDM

        1. 概念:

          用户在分配到一定的频带后,在通信过程中自始至终都占用这个频带。

          频分复用的所有用户在同样的时间占用不同的带宽(频率带宽)资源。

          image-20210410023214368

        2. 优点:充分利用传输介质带宽,系统效率更高;由于技术比较成熟,实现也比较容易。

      2. 时分多路复用TDM

        1. 概念:

          将时间划分为一段段等长的时分复用帧(TDM帧)。

          每一个时分复用的用户在每一个TDM帧中占用固定序号的时隙,所有用户轮流占用信道。

          TDM帧与数据链路层的帧不同,TDM帧是在物理层传送的比特流所划分的帧,标志一个周期(cpu的时间片轮转)。

        这一个周期对应的是在一个周期内可以发送多少个比特。

        1. 频分复用——“并行”

          时分复用——“并发”

        2. 改进的时分复用——统计时分复用STDM(增加了信道的利用率)

          在这里插入图片描述

      3. 波分多路复用WDM

        概念:波分多路复用就是光的频分多路复用,在一根光纤中传输多种不同波长(频率)的光信号,由于波长(频率)不同,所以各路光信号互不干扰,最后再用波长分解复用器将各路波长分解出来。

        image-20210410023757326

      4. 码分多路复用CDM

        注意:码分多址(CDMA)是码分复用的一种方式,注意与码分多路复用区分

        1. 概念:
          把1个比特分为多个码片/芯片(chip),每一个站点被指定一个唯一的m位(m位通常是128位或64位)的芯片序列发送1时,站点发送送芯片序列,发送0时发送芯片序列的反码(在芯片序列中,把0写成-1,正交的码片,CDM原理是利用向量正交为0)

        2. 如何不打架:多个站点同时发送数据的时候,要求各个站点芯片序列相互正交规格内积化是0

          规格内积化:将对应的各位相乘,然后相加,最后在除于总的位数。

        3. 如何合并:各路数据在信道中被线性相加(对应的各个位进行相加)

        4. 如何分离:合并数据和原站(芯片序列 )规格化内积

        image-20210410024814953

3.2.2 、随机访问介质访问控制

动态分配信道,也叫动态媒体接入控制/多点接入

特点:信道并非在用户通信时固定分配给用户。

随机访问介质访问控制:所有用户可以随机发送信息,发送信息时占全部带宽。(不协调 =》冲突 =》 协议解决)

  • ALOHA协议 不听就说
  • CSMA协议 先听再说
  • CSMA/CD协议(重要) 先听再说,边听边说
  • CSMA/CA协议(重要)

1、ALOHA协议

ALOHA协议(非重点)

  1. 纯ALOHA协议

    1. 思想:不监听信道,不按时间槽发送,随机重发。(想发就发)

      image-20210410025200886

      其中T0规定的是一个数据帧的长度。(一般一个数据帧的长度都是用比特来衡量,这里用T0衡量是什么意思呢?)T0指的是这样一个数据帧的发送时间。这里面的发送时间既包括传输时间,也包括传播时间。也就是一个数据帧从刚开始发送到发送成功为止的这样一段时间就叫做T0。

    2. 冲突如何检测?

      如果发生冲突,接收方就会检测出差错,然后发送否定确认帧或者不发送确认帧,发送方在一定时间内收不到确认帧就判断冲突。

    3. 冲突如何解决?

      超时后等一随机时间再重传。

  2. 时隙ALOHA协议

    思想:把时间分成若干个相同的时间片(T0,也可以叫做时间槽),所有用户的时间片开始时刻同步接入网络信道,若发生冲突,则必须等到下一个时间片开始时刻再发送。(控制想发就发的随意性)

    主要特点:

    • 每一个站点在发送帧的时候,只能在一个时间片/时间槽的开始来发送
    • 若站点当前想要发送数据帧,但是还没到一个时间片的开始,那么站点就会等待一个时间片的到来之后在进行发送
    • 如果数据帧在发送过程中发生碰撞,那么这个结点就会在时隙结束之后,也就是经过一个T0之后,发送方发现了这样一个碰撞(接收方没有返回一个确认帧),发送方就判定数据在发送过程中发生了冲突。于是发送方进行超时重传。
    • 发送方进行超时重传是依旧遵循之前的协议。在一个时隙(时间片)开始的时候来重传数据帧

    image-20210410030015295

  3. 关于ALOHA协要知道的事

    1. 纯ALOHA比时隙ALOHA吞吐量更低,效率更低
    2. 纯ALOHA想发就发,时隙ALOHA只有在时间片段开始时才能发
    3. 不冲突概率=p(1-p)^2(N-1) = 1/2e,而时隙ALOHA只考虑一个时隙开始时,所以时隙ALOHA的效率是纯ALOHA效率的两倍

2、CSMA协议

  1. 名词详解

    载波监听多路访问协议CSMA(carrier sense multiple access)

    • CS:载波侦听/监听,每一个站在发送数据之前要检测一下总线上是否有其他计算机在发送数据。

      • 如何监听?

        当几个站同时在总线上发送数据时,总线上的信号电压摆动值将会增大(互相叠加)。当一个站检测到的信号电压摆动值超过一定门限值时,就认为总线上至少有两个站同时在发送数据,表明产生了碰撞,即发生了冲突。

    • MA:多点接入,表示许多计算以多点接入的方式连接在一根总线上

  2. 协议思想:发送帧之前,监听信道

    监听结果:

    1. 信道空闲:发送完整数据帧
    2. 信道忙:推迟发送
      • 1-坚持CSMA
      • 非坚持CSMA
      • P坚持CSMA
  3. 1-坚持CSMA

    • 坚持是指:对于监听信道之后的坚持。

    • 1-坚持CSMA思想:

      如果一个主机要发送信息,那么它先监听信道。

      • 监听结果空闲,则不必等待直接发送
      • 监听结果为忙,则一直监听,直到空闲马上传输
      • 如果有冲突(一段时间内未收到肯定回复),则等待一个随机长的时间再监听(等待随机长的时间这一点与ALOHA协议类似,后同),重复上述过程。
    • 优点:只要媒体空闲,站点就马上发送,避免了媒体利用率的丢失

    • 缺点:假如有两个或两个以上的站点有数据要发送,冲突就不可避免

      比如这多个站点全部采用1-坚持CSMA,则一检测到信道空闲,就会同时发送信息,就会发生冲突。

  4. 非坚持CSMA

    1. 非坚持CSMA思想:

      如果一个主机要发送信息,那么它先监听信道。

      • 监听结果空闲,则不必等待直接发送
      • 监听结果为忙,则等待以后随机时间之后再进行监听
    2. 优点:采用随机的重发延迟时间,可以减少冲突发生的可能性

    3. 缺点:可能存在大家都在延迟等待过程中,使得媒体仍可能处于空闲状态,媒体使用率低。

  5. P-坚持CSMA

    1. P-坚持是指:对于监听信道空闲的处理。

    2. P-坚持CSMA的思想

      如果一个主机要发送信息,那么它先监听信道。

      • 空闲则以p概率直接传输,不必等待;概率1-p等待到下一个时间槽再传输。
      • 忙则等待下一个时隙开始才监听,故叫做持续监听,重复上述过程
    3. 优点:既能像非坚持算法那样减少冲突,又能像1-坚持算法那样减少媒体空闲时间。

    4. 缺点:发生冲突后还是要坚持把数据帧发送完,造成了浪费(这是所有CSMA的缺点,1-坚持、非坚持、P-坚持CSMA都有的缺点)

    三种CSMA的对比(注意P-坚持CSMA不太一样):

    image-20210410032318214

3、CSMA/CD协议(重要)

  1. 大体思想:边发送数据,边监听信道,如果发生冲突就停止发送数据

  2. 名词详解

    载波监听多点接入/CD(也叫碰撞检测CSMA) (carrier sense multiple access with collision detection)

    CS:载波侦听/监听,每一个站点在发送数据之前以及发送数据时都要检测一下总线上是否有其他计算机在发送数据。

    与CSMA不同的是:CSMA/CD在发送数据时也会监听信道

    MA:多点接入,表示许多计算机以多点接入的方式连接在一根总线上。=》 总线型网络

    CD:碰撞检测(冲突检测),“边发送边监听”,应用于适配器边发送数据,边检测信道上信号电压的变化情况,以便判断自己在发送数据时,其他站是否也在发送数据。

    应用于:半双工网络

    主要应用于总线式以太网

  3. 为什么先监听后发送还会产生冲突?

    因为:电磁波在总线上总是以有限的速率传播的。

    传播时延对载波监听的影响:

    image-20210410032912095

    假设:单程端到端传播时延:t 最迟多久才能知道自己发送的数据没和别人碰撞?

    最多是两倍的总线到端的传播时延(2 * t),即总线的端到端的往返传播时延(2 * t)

    只要经过2 * t时间还没有检测到碰撞,就能肯定这次发送不会发生碰撞。

    image-20210410033340427

  4. 如何确定碰撞后的重传时机?

    如果检测到碰撞立即重发会导致恶性循环:

    image-20210410033509456

    截断二进制指数规避算法

    1. 确定基本退避(推迟)时间为争用期2t

    2. 定义参数k,它等于重传次数,但k不超过10,即k=min[重传次数,10]。

      • 当重传次数不超过10时,k等于重传次数;
      • 当重传次数大于10时,k就不再增大而一直等于10。
    3. 从离散的整数集合[0,1,…,2^k-1]中随机取出一个数r,重传所需要退避的时间就是r倍的基本退避时间,即2 * r * t。

    4. 当重传达16次(最大重传次数)仍不能成功时,说明网络太拥挤,认为此帧永远无法正确发出,抛弃此帧并向高层报告出错。

      截断二进制指数规避算法使用示例

      在这里插入图片描述

    例题:

    image-20210410034142801

  5. 最小帧长问题:

    A站发了一个很短的帧,但是发生了碰撞,不过帧在发送完毕后才检测出发生碰撞,没法停止发送。为了使CSMA/CD协议有意义,要定义一个最小帧长。

    帧的传输时延至少要两倍于信号在总线中的传播时延

    帧的传输时延 = 帧长(bit)/ 数据传输率 >= 2 * 总线传播时延

    即:最小帧长 = 2 * 总线传播时延 * 数据传输速率 = 2 * t * 数据传输速率

    补充:以太网规定最短帧长为64B,凡是长度小于64B的都是由于冲突而异常终止的无效帧。因此,以太网为了达到这个最小帧长,对于一个比较短的帧,它会对它进行一个填充操作,使它的帧长大于等于64B,然后才能将它放到链路上进行发送。

    脑图:

    image-20210410034655978

4、CSMA/CA协议(重要)

CA:对碰撞的避免

CD:对碰撞的检测

  1. 名词解释

    1. 载波监听多点接入/CA(碰撞避免CSMA,不能检测碰撞)(carrier sense multiple access with collision avoidance)

    2. 为什么要有CSMA/CA?

      主要是因为:CA主要应用于无线局域网

      1. 在无线局域网中无法使用CD协议,不能做到360度全面检测碰撞

        • CD主要应用于总线式以太网
      2. 隐蔽站问题,当A和C都检测不到信号,认为信道空闲时,同时向终端B发送数据帧,就会发生冲突。

        C相对于A就是隐蔽站

    3. 有礼貌的CSMA/CA:不光是先听后发,在听了之后,发送数据之前会等一小段时间。

  2. CSMA/CA工作原理

    发送数据之前,先检测信道是否空闲。

    若空闲则发出RTS(request to send),RTS包括发射端的地址、接收端的地址、下一份数据将持续发送的时间等信息;RTS可发可不发,发RTS是为了解决隐蔽站的问题

    若信道忙,则等待。接收端收到RTS后,将响应CTS(clear to send)

    RTS和CTS就是用来解决隐蔽站的问题:

    • 发送端收到CTS后,开始发送数据帧(同时开始预约信道:发送方告知其他站点自己要传多久数据)

    • 接收端收到数据帧后,将用CRC(CRC循环冗余检验)来检验数据是否正确,正确则响应ACK帧

    • 发送方收到ACK就可以进行下一个数据帧的发送,若没有则一直重传至规定重发次数为止

      (这里跟CD协议一样,采用二进制指数退避算法来确定随机的推迟时间。)

  3. 三个机制实现碰撞避免

    1. 预约信道
    2. ACK帧
    3. RTS/CTS帧(可选,主要是解决隐蔽站的问题)
  4. CD和CA协议的比较

    • 相同点:

      CD和CA机制都从属于CDMA的思路,其核心就是先听再说

      换言之,两个在接入信道前都要进行监听。当发现信道空闲后,才能进行接入。

    • 不同点:

      1. 传输介质不同
        • CD用于总线式以太网【有线】
        • CA用于无线局域网【无线】
      2. 载波检测方式不同
        • 应传输介质不同,CD和CA的检测方式也不同。
        • CD通过电缆中电压的变化来检测,当数据发生碰撞时,电缆中的电压就会随着发生变化;
        • CA采用能量检测(ED)、载波检测(CS)和能量载波检测三种检测信道空闲的方式。
        • CSMA/CD检测冲突CSMA/CA避免冲突,两者出现冲突后都会进行有上限的重传。

3.2.3 、轮询访问介质访问控制

信道划分介质访问控制(MAC Multiple Access Control)协议:

  • 基于多路复用技术划分资源
  • 网络负载重时,共享信道效率高,且公平
  • 网络负载轻时:共享信道效率低

随机访问MAC协议:

  • 用户根据意愿随机发送信息,发送信息时可独占信道带宽
  • 网络负载重时,产生冲突开销
  • 网络负载轻时,共享信道效率高,单个结点可利用信道全部带宽

轮询访问MAC协议/轮流协议/轮转访问MAC协议:

综合信道划分介质访问控制协议和随机访问MAC协议,既不产生冲突,也要发送时占用全部带宽

  • 轮询协议

    主结点轮流“邀请”从属结点发送数据

    image-20210504145458454

    问题:

    1. 轮询开销
    2. 靠后结点有等待延迟
    3. 单点故障:主结点发生故障
  • 令牌传递协议(重要)

    image-20210504145553598

主机

TCU(转发器)

令牌:一个特殊格式的MAC控制帧,不含任何信息

控制信道的使用,确保同一时刻只有一个结点独占信道。

每一个结点都可以在一定的时间内(令牌持有时间内)获得发送数据的权利,并不是无限制地持有令牌

问题:

  1. 令牌开销
  2. 等待延迟
  3. 单点故障(一个主机宕机后,线路故障)

通常应用于令牌环网(物理星型拓扑,逻辑环形拓扑)

采用令牌传送方式的网络常用于负载较重、通信量较大的网络中。

介质访问控制总结:

image-20210504150943372

3.3.1 、局域网基本概念和体系结构

局域网(LAN,Local Area Network)

  1. 概念:是指某一区域内由多台计算机互连成的计算机组,使用广播信道

  2. 特点

    1. 覆盖地理范围小,只在一个相对独立的局部范围内联,如一座或集中的建筑群内。
    2. 使用专门铺设的传输介质(双绞线、同轴电缆)进行联网,数据传输速率高(10Mb/s-10Gb/s)
    3. 通信延迟时间短,误码率低,可靠性高
    4. 各站点为平等关系,共享传输信道
    5. 多采用分布式控制和广播式通信,能进行广播和组播
  3. 决定局域网的主要要素为:网络拓扑传播介质介质访问控制方法

    1. 局域网的网络拓扑

      1. 星型拓扑

        中心节点是控制中心,任意两个节点间的通信最多只需两步,传输速度快,并且网络构形简单、建网容易、便于控制和管理。但这种网络系统,网络可靠性低,网络共享能力差,有单点故障问题

      2. 总线型拓扑(常用)

        网络可靠性高、网络节点间响应速度快、共享资源能力强、设备投入量少、成本低、安装使用方便,当某个工作站节点出现故障时,对整个网络系统影响小。

      3. 环形拓扑

        系统中通信设备和线路比较节省。有单点故障问题;由于环路是封闭的,所以不便于扩充,系统响应延时长,且信息传输效率相对较低。

      4. 树形拓扑

        易于拓展,易于隔离故障,也容易有单点故障。

    2. 局域网传输介质

      1. 有线局域网 常用介质:双绞线、同轴电缆、光纤
      2. 无线局域网 常用介质:电磁波
    3. 局域网介质访问控制方法

      1. CSMA/CD 常用于总线型局域网,也用于树型网络

      2. 令牌总线常用于总线型局域网,也用于树型网络

        它是把总线型或树型网络中的各个工作站按一定顺序如按接口地址大小排列形成一个逻辑环。只有令牌持有者才能控制总线,才有发送信息的权力。

      3. 令牌环 用于环形局域网,如令牌环网

        • 逻辑拓扑:环型(逻辑拓扑主要受通信思想的制约)
        • 物理拓扑:星型(物理拓扑主要受限制的制约)
  4. 局域网的分类

    1. 以太网

      以太网是应用最广泛的局域网,包括标准以太网(10Mbps)、快速以太网(100Mbps)、千兆以太网(1000Mbps)和10G以太网,它们都符合IEEE 802.3系列标准规范。

      逻辑拓扑总线型,物理拓扑是星型或拓展星型。使用CSMA/CD

    2. 令牌环网

      造价高,不是很实用,已是明日黄花

      物理拓扑星型,逻辑拓扑环型

    3. FDDI网(Fiber Distributed Data Interface)(了解)

      用的很少

      物理双环拓扑,逻辑环型拓扑

    4. ATM网(Asynchronous Transfer Mode)(了解)

      较新型的单元交换技术,使用53字节固定长度的单元进行交换

    5. 无线局域网(Wireless Local Area Network,WLAN)

      采用IEEE 802.11标准

  5. IEEE 802标准

    IEEE802系列标准是IEEE802LAN/MAN标准委员会制定的局域网、城域网技术标准(1980年2月成立)其中最广泛使用的有以太网、令牌环网、无线局域网。这一系列标准中的每一个子标准都由委员会中的一个专门工作组负责。

    1. IEEE802.3标准

      以太网介质访问控制协议及物理层技术规范

    2. IEEE802.5标准

      令牌环网的介质访问控制协议及物理层技术规范

    3. IEEE802.8标准

      光纤技术咨询组,提供有关光纤联网的技术咨询(FDDI网

    4. IEEE802.11

      无线局域网(WLAN)的介质访问控制协议及物理层技术规范

  6. MAC子层和LLC子层

    IEEE802标准所描述的局域网参考模型只对应OSI参考模型的数据链路层和物理层,它将数据链路层划分为逻辑链路层LLC子层介质访问控制MAC子层

    1. LLC负责识别网络层协议,然后对它们进行封装。LLC报头告诉数据链路层一旦帧被接收到时,应当对数据包做何处理。

      为网络层提供服务:无确认无连接、面向连接、带确认无连接、高速传送。

    2. MAC子层的主要功能包括数据帧的封装/卸装,帧的寻址和识别,帧的接收与发送,链路的管理,帧的差错控制等。

      MAC子层的存在屏蔽了不同物理链路种类的差异性。

    image-20210504150742911

脑图

image-20210504150832973

3.3.2、以太网概述

  1. 概念

    以太网(Ethernet)指的是由Xerox公司创建并由Xerox、Intel和DEC公司联合开发的基带总线局域网规范,是当今现有局域网采用的最通用的通信协议标准。以太网络使用CSMA/CD(载波监听多路访问及冲突检测)技术

  2. 以太网在局域网各种技术中占统治地位

    1. 造价低廉(以太网网卡不到100块)
    2. 是应用最广泛的局域网技术
    3. 比令牌环网、ATM网便宜,简单
    4. 满足网络速率的要求,10Mbps-10Gbps
  3. 以太网的两个标准

    1. DIX Ethernet V2:第一个局域网产品(以太网)规约。
    2. IEEE802.3:IEEE802委员会802.3工作组制定的第一个IEEE的以太网标准

    这两个标准的区别不大,只是在帧的格式上有两个字节的差异,
    因此只要满足两个标准中的一个都叫以太网,以太网也叫802.3局域网

  4. 以太网提供无连接、不可靠的服务

    • 无连接:发送方和接收方之间无“握手过程”。
    • 不可靠:
      • 不对发送方的数据帧编号,
      • 接收方不向发送方进行确认,
      • 差错帧直接丢弃,
      • 差错纠正由高层负责。

    以太网只实现无差错接受,不实现可靠传输

  5. 以太网传输介质和拓扑结构的发展

    • 传输介质:粗同轴电缆–>细同轴电缆–>双绞线+集线器
    • 物理拓扑:总线型–>星型
      • 使用集线器的以太网在逻辑上仍是一个总线网,各站共享逻辑上的总线,使用的还是CSMA/CD协议
    • 以太网拓扑:逻辑上总线型,物理上星型
  6. 10BASE-T以太网

    • 10BASE-T是传送基带信号的双绞线以太网,T表示采用双绞线,现10BASE-T采用的是无屏蔽双绞线(UTP),传输速率是10Mb/s
    • 物理上采用星型拓扑、逻辑上总线型,每段双绞线最长100m
    • 采用曼彻斯特编码
    • 采用CSMA/CD介质访问控制
  7. 适配器与MAC地址

    • 计算机与外界有局域网的连接是通过通信适配器的。
      • 网络接口板
      • 网络接口卡NIC(network interface card),现在不再使用网卡
      • 适配器上装有处理器和存储器(包括RAM和ROM)
      • ROM上有计算机硬件地址MAC地址
    • 在局域网中,硬件地址又称为物理地址,或MAC地址。【实际上是标识符
    • MAC地址:每个适配器由全球唯一的二进制地址,前24位代表厂家(有IEEE规定),后24位厂家自己指定。常用6个十六进制数表示。即:这个是12个16进制数决定,前六位是厂家,后六位是各个网络制造商自己规定的。如02-60-8c-e4-b1-21
  8. 以太网MAC帧

    • 最常用的MAC帧是以太网V2的格式

      img

      • 目的地址有三种情况
        • 单播地址,一个专有的MAC地址。传播给固定主机
        • 广播地址:8B的前导码全”1”(二进制),或者全”F”(十六进制)。会发生给所有主机
        • 多播地址
    • 与IEE 802.3的区别:

      1. 第三个字段是长度/类型
      2. 当长度/类型字段值小于0x0600时,数据字段必须装入LLC子层。
  9. 高速以太网

    1. 100BASE-T以太网

      • 双绞线上传送100Mb/s基带信号星型拓扑以太网,仍使用IEEE802.3的CSMA/CD协议。

      • 支持全双工和半双工,可在全双工方式下工作而无冲突(不使用CSMA/CD协议)。

        • 全双工(交换机可以隔离冲突域,每一个交换机的端口都是一个冲突域,一个主机在一个冲突域当中不存在冲突)

          image-20210504153357428

    2. 吉比特以太网

      • 光纤或双绞线上传送1Gb/s信号
      • 支持全双工和半双工,可在全双工方式下工作而无冲突
    3. 10吉比特

      • 10吉比特以太网在光纤上传送10Gb/s信号
      • 只支持全双工,无争用问题

脑图

image-20210504153615323

3.3.3、无线局域网

IEEE802.11是无线局与通信用的标准,它是由IEEE所定义的无线通信的标准

wifi是WLAN的一种应用,WLAN可以比较大。

802.11的MAC帧头格式

image-20210504153957809

image-20210504154046849

总结一下:

  • IBSS就是一个服务集内的移动站点不通过基站的直接通信
  • To AP 就是服务集内的移动站点向基站的通信
  • From AP 就是服务集内基站向移动站的通信
  • WDS就是不同服务集内的两个移动站之间的通信(漫游)

无线局域网的分类:

  1. 有固定基础设施无线局域网

    image-20210504154529596

  2. 无固定基础设施无线局域网的自组织网络

    image-20210504154658400

3.3.4、PPP协议和HDLC协议

广域网(WAN,Wide Area Network)通常跨接很大的物理范围,所覆盖的范围从几十公里到几千公里,它能连接多个城市或国家,或横跨几个洲并能提供远距离通信,形成国际性的远程网络。

广域网的通信子网主要使用分组交换技术。广域网的通信子网可以利用公用分组交换网、卫星通信网和无线分组交换网,它将分布在不同地区的局域网或计算机系统互连起来,达到资源共享的目的。如因特网(Internet)是世界范围内最大的广域网。

广域网强调资源共享,局域网强调数据传输

image-20210504154840374

  1. PPP协议:

    1. 点对点协议PPP(Point-to-Point Protocol)是目前使用最广泛的数据链路层协议,用户使用拨号电话接入因特网时一般都是用PPP协议

      • 只支持全双工链路
    2. PPP协议应满足的要求:

      • 简单 对于链路层的帧,无需纠错,无需序号,无需流量传输
      • 封装成帧 帧定界符
      • 透明传输 与帧定界符一样比特组合的数据应该如何处理:异步线路用字节填充同步线路用比特填充
      • 多种网络层协议 封装的IP数据报可以采用多种协议
      • 多种类型链路 串行/并行,异步/同步,光/电
      • 差错检测 错就丢弃
      • 检测连接状态 链路是否正常
      • 最大传送单元 数据部分最大长度MTU(默认不超过1500B)
      • 网络层地址协商 知道通信双方的网络层地址
      • 数据压缩协商
    3. PPP协议无需满足的要求:(纠流编多)

      • 纠错
      • 流量控制
      • 对帧编序号
      • 不支持多点线路
    4. PPP协议的三个组成部分:

      1. 一个将IP数据报封装到串行链路(同步串行/异步串行)的方法
      2. 链路控制协议LCP:建立并维护数据链路连接。(物理连接)
        • 应用:身份验证
      3. 网络控制协议NCP:PPP可支持多种网络层协议,每个不同的网络层协议都要一个相应的NCP来配置,为网络层协议建立和配置逻辑连接(逻辑连接)
    5. PPP协议的状态图

      img

    6. PPP协议的帧格式

      • 帧格式是什么东西?

      • 还有MAC帧格式(以字节为单位)(7E\7D)

        img

  2. HDLC协议

    高级数据链路控制(High-Level Data Link Control或简称HDLC),是一个在同步网上传输数据、面向比特的数据链路层协议,它是由国际标准化组织(ISO)根据IBM公司的SDLC(SynchronousData Link Control)协议扩展开发而成的。

    数据报文可透明传输,用于实现透明传输的“0比特插入法”易于硬件实现

    采用全双工通信

    所有帧采用CRC检验,对信息帧进行顺序编号可防止漏收或重份,传输可靠性高

    HDLC的站:主站从站复合站

    1. 主站的主要功能是发送命令(包括数据信息)帧、接收响应帧,并负责对整个链路的控制系统的初启、流程的控制、差错检测或恢复等。
    2. 从站的主要功能是接收由主站发来的命令帧,向主站发送响应帧,并且配合主站参与差错恢复等链路控制。
    3. 复合站的主要功能是既能发送,又能接收命令帧和响应帧,并且负责整个链路的控制。

    HDLC的三种数据操作方式:

    1. 正常响应方式

      从站发送消息要经过主站的同意,主站命令从站发送数据,从站才可以发送数据

    2. 异步平衡方式

      每一个复合站都可以对其他站的数据传输,每个站都是平等的地位

    3. 异步响应方式

      从站可以不经过主站的同意就进行数据的传输

    HDLC的帧格式:

    image-20210504160825465

    对于地址A:取决于当前选择的数据操作方式

    • 正常响应方式/异步响应方式:从站的地址
    • 异步平衡方式:对应站(应答站),也就是对方的地址

    对于控制C:决定了HDLC帧的类型(无奸细)

    • 信息帧(I)**:第1位为0,用来传输数据信息,或使用捎带技术对数据进行确认**;
    • 监督帧(S)**:10**, 用于流量控制和差错控制,执行对信息帧的确认、请求重发和请求暂停发送等功能
    • 无编号帧(U)**:11**, 用于提供对链路的建立、拆除等多种控制功能。
  3. PPP协议 & HDLC协议

    相同点:

    • HDLC、PPP只支持全双工链路
    • 都可以实现差错检测,但不纠正差错
    • 都可以实现透明传输
      • 关于透明传输的一点小差别:
        • PPP协议既可以实现0比特填充的比特型的填充方法,也可以实现字节填充的方法
        • HDLC协议只能实现0比特填充的比特型的填充方法

    区别:

    PPP协议 面向字节 2B协议字段 无序号和确认机制 不可靠
    HDLC协议 面向比特 没有 有编号和确认机制 可靠

image-20210504162250883

脑图

image-20210504162337180

3.4.1、链路层设备

3.4.1.1、物理层扩展以太网

采用光纤的方式

image-20210504162926611

采用集线器的方式

image-20210504163113104

可以扩展以太网,但是集线器会无脑将一个设备的所有消息转发到集线器所连的所有设备,故会将所连接的所有设备变成一个大的冲突域,同时只能有两台设备进行通信,且设备越多,冲突越多。由此诞生了网桥

3.4.1.2、数据链路层扩展以太网

采用网桥的方式:

网桥根据MAC帧的目的地址对帧进行转发过滤。当网桥收到一个帧时,并不向所有接口转发此帧,而是先检查此帧的目的MAC地址,然后再确定将该帧转发到哪一个接口,或者是把它丢弃(即过滤)。

image-20210504163354054

网桥优点:

  • 过滤通信量,增大吞吐量。
  • 扩大了物理范围。
  • 提高了可靠性。
  • 可互连不同物理层、不同MAC子层和不同速率的以太网。

网桥的分类:

  • 透明网桥:“透明”指以太网上的站点并不知道所发送的帧将经过哪几个网桥,是一种即插即用设备——自学习。

    • 关于自学习

      image-20210504164127428

  • 源路由网桥:在发送帧时,把详细的最佳路由信息( 路由最少/时间最短)放在帧的首部中。

    方法:源站以广播方式向欲通信的目的站发送一个发现帧

    image-20210504164759387

采用交换机的方法

image-20210504165249409

以太网交换机的两种交换方式:

  • 直通式交换机:查完目的地址(6B) 就立刻转发。
    • 优点:延迟小
    • 缺点:可靠性低,无法支持具有不同速率的端口的交换。
  • 存储转发式交换机(常用):将帧放入高速缓存,并检查否正确,正确则转发,错误则丢弃。
    • 优点:可靠性高,可以支持具有不同速率的端口的交换
    • 缺点:延迟大

冲突域 VS 广播域

  • 冲突域:在同一个冲突域中的每一个节点都能收到所有被发送的帧。简单的说就是同一时间内只能有一台设备发送信息的范围
  • 广播域:网络中能接收任一设备发出的广播帧的所有设备的集合。简单的说如果站点发出一个广播信号,所有能接收收到这个信号的设备范围称为一个广播域
能否隔离冲突域 能否隔离广播域
物理层设备[傻瓜]
(中继器、集线器)
× ×
链路层设备[路人]
(网桥、交换机)
×
网络层设备[大佬]
(路由器)

相关例题:(广播域看路由器,冲突域:交换机每一个接口就是一个冲突域)

image-20210504170220974

脑图:

image-20210504170343115

3.5、第三章总结

image-20210504170510895

第四章 网络层

img

img

4.1、网络层的任务与功能

image-20210505155309690

4.2、数据交换方式

1、网络的“掌中宝”——路由器

image-20210505155454971

2、为什么要数据交换

image-20210505155727387

3、数据交换的方式

数据交换可分为三种方式:

  • 电路交换
  • 报文交换
  • 分组交换
    • 数据报方式
    • 虚电路方式

4、电路交换

image-20210505160852979

5、报文交换

image-20210517160352740

6、分组交换

image-20210505161751869

7、报文交换 & 分组交换

image-20210505162605543

8、三种数据交换方式比较总结

image-20210505162837182

9、数据报方式&虚电路方式

image-20210505163608737

10、几种传输单元名词辨析

image-20210505164202246

11、数据报(应用于因特网)

image-20210505164423826

12、虚电路

image-20210505164740761

13、数据报 & 虚电路

image-20210505164918003

4.3.1、IP数据报格式

1、TCP/IP协议栈

image-20210505165458233

2、IP数据报格式

image-20210505165620439

image-20210505171044015

image-20210517165201436

4.3.2、IP数据报分片

1、最大传送单元MTU

image-20210505171323581

2、IP数据报格式——分片相关

image-20210505171749113

3、IP数据报分片例题

image-20210505172207007

4、IP数据报格式——相关单位(一种八片首饰)

image-20210505172335655

4.3.3、IPv4地址

1、IP地址

image-20210505172546692

2、IP编址的历史阶段

  • 分类的IP地址
  • 子网的划分
  • 构成超网(无分类编址方法)

3、分类的IP地址

image-20210505172901070

1、互联网中的IP地址:

image-20210505173055123

2、分类的IP地址:

image-20210505173438999

3、特殊IP地址

image-20210505173834400

4、私有IP地址

image-20210505174001052

5、分类IP使用个数

image-20210505192925617

4.3.4、网络地址转换NAT

image-20210505193330114

网络地址转换NAT:

image-20210505193808509

4.3.5、子网划分和子网掩码

1、子网划分

image-20210505194037834

image-20210505194204411

image-20210505194311386

2、子网掩码

子网掩码:是为了区分网段的 掩码和主机号与主机号比较来判断属不属于该网段

image-20210505194758394

相关习题:

习题1:

image-20210505195105282

习题2:

image-20210505200049573

3、使用子网时分组的转发

image-20210505200430899

4.3.6、无分类编址CIDR

image-20210505201051592

image-20210505201235591

构成超网

image-20210505201651932

最长前缀匹配

image-20210505202925728

image-20210505203331296

4.3.7、ARP协议

1、发送数据的过程

IP1向IP3发送数据:

image-20210505203902705

IP1向IP5发送数据:

image-20210505212537717

2、ARP协议

image-20210505212854516

ARP地址的相关习题

image-20210505213228348

4.3.8、DHCP协议

1、主机如何获得IP地址?

image-20210505223656891

2、DHCP协议

image-20210505224535421

4.3.9、ICMP协议

1、ICMP协议作用

为了更有效地转发IP数据报和提高交付成功的机会

2、网际控制报文协议ICMP

image-20210505225626767

3、ICMP差错报告报文(5种)

image-20210505225923078

4、ICMP差错报告报文数据字段

image-20210505230122748

5、不应发送ICMP差错报文的情况

image-20210505230238989

6、ICMP询问报文

image-20210505230910474

7、ICMP的应用

image-20210505231645689

4.4、IPv6

1、为什么有IPv6

image-20210505232136621

2、IPv6数据报格式

image-20210505233127480

image-20210505233806463

3、IPv6 VS IPv4

image-20210505234147607

4、IPv6地址表示形式

image-20210505234335367

5、IPv6基本地址类型

image-20210505234545274

6、IPv6向IPv4过渡的策略

image-20210505234827815

7、脑图

image-20210505234924693

4.5、路由算法及路由协议

1、路由算法

image-20210506000134403

2、路由算法的分类

image-20210506000028662

3、分层次的路由选择协议

image-20210506000422048

image-20210506000456424

4.6.1、RIP协议与距离向量算法

1、RIP协议

image-20210506000832013

2、RIP协议和谁交换?多久交换一次? 交换什么?

image-20210506001325532

3、距离向量算法

image-20210506001614574

相关例题:

image-20210506001913695

image-20210506002345004

4、RIP协议的报文格式

image-20210506002716945

5、RIP协议好消息传得快,坏消息传得慢

image-20210506002829463

image-20210506002927178

image-20210506003058016

image-20210506003123096

image-20210506003157668

脑图

补充下,RIP与距离向量算法不一样:因数据报服务在分组转发时,每个分组独立选择路由转发,从而引出了路由选择协议。RIP叫路由信息协议。为了找出RIP的最短距离引出了距离向量算法。

image-20210506003352720

4.6.2、OSPF协议与链路状态算法

1、OSPF协议

image-20210506003646135

2、链路状态路由算法

image-20210506004006614

3、OSPF的区域

image-20210506004158704

4、OSPF分组

image-20210506004522231

5、OSPF其他特点

image-20210506004650863

4.6.3、BGP协议

1、BGP协议

image-20210506004817609

2、BGP协议交换信息的过程

image-20210506005743562

image-20210506005824214

image-20210506005844401

3、BGP协议报文格式

image-20210506005948843

4、BGP协议特点

image-20210506010034467

5、BGP-4的四种报文

image-20210506010126542

6、三种路由协议比较

image-20210506010314753

image-20210506010353924

4.7、IP组播

1、IP数据报的三种传输方式

image-20210506010805488

image-20210506010910591

image-20210506011014041

2、IP组播地址

image-20210506011327598

3、硬件组播

image-20210506012222229

4、IGMP协议与组播路由选择协议

image-20210506012400642

5、网际组管理协议IGMP

image-20210506012545893

ICMP和IGMP都使用IP数据报传递报文

6、IGMP工作的两个阶段

image-20210506012720613

7、组播路由选择协议

image-20210506012924832

image-20210506013034949

组播路由选择协议常使用的三种算法:

  • 基于链路状态的路由选择
  • 基于距离-向量的路由选择
  • 协议无关的组播(稀疏/密集)

8、脑图

image-20210506013212541

4.8、移动IP

1、移动IP相关术语

image-20210506013719153

image-20210506013851391

2、移动IP通信过程

image-20210506014144890

image-20210506014312720

4.9、网络层设备

1、路由器

image-20210506014602052

2、输入端口对线路上收到的分组的处理

image-20210506014713942

image-20210506014800212

3、三层设备的区别

image-20210506014928456

4、路由表与路由转发

image-20210506015046620

4.10、网络层总结

image-20210506015143791

image-20210506015239776

image-20210506015254927

image-20210506015310030

image-20210506015341546

image-20210506015440181

image-20210506015550548

image-20210506015658610

image-20210506015721592

image-20210506015734462

image-20210506015810700

第五章 传输层

在这里插入图片描述

5.1、传输层概述

1、什么是传输层

image-20210509091650713

2、传输层的两个协议

image-20210509130716955

3、传输层的寻址与端口

image-20210509131021768

image-20210509131114772

5.2、UDP协议

1、用户数据报协议UDP概述

image-20210509131452260

2、UDP首部格式

image-20210509131719115

3、UDP校验

image-20210509131902697

image-20210509132143163

5.3.1、TCP协议特点和TCP报文段格式

1、TCP协议的特点

image-20210509132448448

2、TCP协议的特点 & TCP报文段首部格式

image-20210509133753564

image-20210509132848748

TCP首部——序号:

image-20210509133439721

TCP首部——确认号

image-20210509133333378

相关控制位:

image-20210509135329970

TCP首部控制位——紧急位URG

image-20210509134247734

TCP首部控制位——推送位PSH

image-20210509134713094

TCP首部——窗口

image-20210509135449995

TCP首部——紧急指针

image-20210509135608155

5.3.2、TCP连接管理

1、TCP连接管理

image-20210509135759910

2、TCP的连接建立

image-20210509140112306

3、SYN洪泛攻击

image-20210509143604587

4、TCP的连接释放

image-20210509143655748

image-20210509144000502

5.3.3、TCP可靠传输

1、TCP可靠传输

image-20210509144114911

2、序号

image-20210509144335882

3、确认

image-20210509144702310

4、重传

image-20210509144948012

image-20210509145112616

5.3.4、TCP流量控制

image-20210509145413434

image-20210509150152054

image-20210509150246065

5.3.5、TCP拥塞控制

1、TCP拥塞控制

image-20210509150503776

2、拥塞控制四种算法

image-20210509150710651

3、拥塞控制四种算法——慢开始和拥塞避免

传输轮次:

image-20210509151158840

image-20210509151631221

4、拥塞控制四种算法——快重传和快恢复

image-20210509152156033

5.4、传输层总结

image-20210509152240332

image-20210509152327273

image-20210509152352593

第六章 应用层

img

6.1、网络应用模型

1、应用层概述

image-20210510103902295

2、网络应用模型

1、客户/服务器模型(Client/Server)

image-20210510104239979

2、P2P模型(Peer-to-peer )

image-20210510104646313

6.2、域名解析(DNS)系统

1、DNS系统

image-20210510104903931

image-20210510105032397

2、域名

image-20210510105410771

域名树

image-20210510105508640

3、域名服务器

image-20210510105950375

image-20210510110153218

image-20210510110230680

4、域名解析过程

image-20210510110758844

6.3、文件传输协议FTP

1、文件传送协议

image-20210510111014038

2、FTP服务器和客户端

image-20210510111137982

3、FTP工作原理

image-20210510111724834

image-20210510112854765

image-20210510111833815

6.4、电子邮件

1、电子邮件系统概述——电子邮件的的信息格式

image-20210510114256285

2、电子邮件系统概述——组成结构

image-20210510114344542

image-20210510114417744

3、简单邮件传送协议SMTP

image-20210510114439779

image-20210510114512955

4、MIME

image-20210510114546291

5、邮局协议POP3

image-20210510114622467

6、网际报文存取协议IMAP

image-20210510114808711

7、基于万维网的电子邮件

image-20210510114701163

脑图

image-20210510114720692

6.5、万维网和HTTP协议

1、万维网概述

image-20210510115838121

2、超文本传输协议HTTP

image-20210510115923321

3、HTTP协议的特点

image-20210510120008065

4、HTTP协议的连接方式

image-20210510120102536

5、超文本传输协议HTTP——报文结构

image-20210510123648179

image-20210510123711370

image-20210510123757619

参考链接:

计算机网络(2019 王道考研)

计算机网络思维导图

Vue

Vue官网

一、邂逅Vuejs

1、遇见Vuejs

1、认识Vuejs

  • Vue (读Vue (读音 /vjuː/,类似于 view)音 /vjuː/,类似于 view)

  • Vue是一个渐进式的框架,渐进式的框架:

    • 渐进式意味着你可以将Vue作为你应用的一部分嵌入其中,带来更丰富的交互体验。
    • 如果你希望将更多的业务逻辑使用Vue实现,那么Vue的核心库以及其生态系统。比如Core+Vue-router+Vuex,也可以满足你各种各样的需求。
  • 与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。Vue 的核心库只关注视图层,不仅易于上手,还便于与第三方库或既有项目整合。另一方面,当与现代化的工具链以及各种支持类库结合使用时,Vue 也完全能够为复杂的单页应用提供驱动。

2、Vue的特点和Web开发中常见的高级功能

  • 解耦视图和数据
  • 可复用的组件
  • 前端路由技术
  • 状态管理
  • 虚拟DOM

2、安装Vuejs

安装

1、方式一:下载和引入

官网上直接下载vue.js文件引入到项目(本地)中,其中有开发环境生产环境

注意:

在下载时不能直接点击,直接点击的话你将看到vue.js的源码。应该右键选中从链接另存文件

image-20210320014752045

其中

  • 开发环境用在开发的时候,其中的代码包含了有帮助的命令行警告,方便程序员查看源代码,但相对的文件比较大。

  • 生产环境用在发布产品的时候,其中的代码都是经过压缩的,优化了尺寸和速度,文件也比较小,方便用户下载,但代码的可读性极差。

一句话总结:开发环境面向的是程序员,生产环境面向的是用户。

2、方式二:直接CDN引入

你可以在你的项目中直接CDN(外部)引入:

1
2
3
4
5
6
7
8
9
10
<!-- 开发环境版本,包含了有帮助的命令行警告 -->
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>

<!-- 生产环境版本,优化了尺寸和速度 -->
<script src="https://cdn.jsdelivr.net/npm/vue"></script>

<!--如果你使用原生 ES Modules,这里也有一个兼容 ES Module 的构建文件-->
<script type="module">
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js'
</script>

3、方式三:NPM安装

在用 Vue 构建大型应用时推荐使用 NPM 安装[1]。NPM 能很好地和诸如 webpackBrowserify 模块打包器配合使用。同时 Vue 也提供配套工具来开发单文件组件

1
2
# 最新稳定版
$ npm install vue

3、第一个Vuejs程序

image-20210320015424885

1、代码的执行

  1. 阅读JavaScript代码,程序发现创建了一个Vue对象;
  2. 创建Vue对象的时候,传入了一些options:{}
    • {}中包含了el属性:该属性决定了这个Vue对象挂载到哪一个元素上,很明显,我们这里是挂载到了id为app的元素上;
    • {}中包含了data属性:该属性中通常会存储一些数据:
      • 这些数据可以是我们直接定义出来的,比如像上面代码这样
      • 也可能是来自网络,从服务器加载的

2、浏览器执行代码的流程

  1. 执行到10~13行代码显然出对应的HTML;
  2. 执行第16行代码创建Vue实例,并且对原HTML进行解析和修改

3、响应式

Vue代码是可以实现响应式的。在浏览器里进入开发者模式F12中的console。在里面修改代码可以实现浏览器的内容也随着修改而响应着改变。

image-20210320020223661

4、Vue与JavaScript (两种编程范式)

  • 命令式编程(JavaScript )

    命令式编程的主要思想是关注计算机执行的步骤,即一步一步告诉计算机先做什么再做什么。

    优点:数据和界面完全分离,不需要js创建页面元素等操作

  • 声明式编程(Vuejs)

    声明式编程是以数据结构的形式来表达程序执行的逻辑。它的主要思想是告诉计算机应该做什么,但不指定具体要怎么做。

    优点:当数据发生改变时界面自动发生改变(响应式)

5、Vue的MVVM

1、是什么MVVM

维基百科官方解释

MVVMModel–view–viewmodel)是一种软件架构模式

MVVM有助于将图形用户界面的开发与业务逻辑后端逻辑(数据模型)的开发分离开来,这是通过置标语言或GUI代码实现的。MVVM的视图模型是一个值转换器,[1] 这意味着视图模型负责从模型中暴露(转换)数据对象,以便轻松管理和呈现对象。在这方面,视图模型比视图做得更多,并且处理大部分视图的显示逻辑。[1] 视图模型可以实现中介者模式,组织对视图所支持的用例集的后端逻辑的访问。

image-20210402232753389

2、Vue的MVVM

image-20210320022023488

  • View层:
    • 视图层
    • 在前端开发中,通常就是DOM层
    • 主要的作用是给用户展示各种信息
  • Model层:
    • 数据层
    • 数据可能是我们固定的死数据,但更多的是来自我们服务器,从网络上请求下来的数据
  • VueModel层:
    • 视图模型层
    • 视图模型层是View和Model沟通的桥梁
    • 一方面它实现了Data Binding,也就是数据绑定,将Model的改变实时的反应到View中
    • 另一方面它实现了DOM Listener,也就是DOM监听,当DOM发生一些事件(点击、滚动、touch等)时,可以监听到,并在需要的情况下改变对应的Data

3、计数器的MVVM示例

计数器:点击 + 计数器+1,点击 - 计数器 -1

在Vue对象中

  • 新属性:methods。该属性用于在Vue对象中定义方法。
  • 新的指令:@click, 该指令用于监听某个元素的点击事件,并且需要指定当发生点击时,执行的方法(方法通常是methods中定义的方法)

image-20210320022244195

image-20210320022417139

计数器中就有严格的MVVM思想:

  • View依然是我们的DOM
  • Model就是我们我们抽离出来的obj
  • ViewModel就是我们创建的Vue对象实例

01-计数器的MVVM

它们之间如何工作呢?

  1. 首先ViewModel通过Data Binding让obj中的数据实时的在DOM中显示。
  2. 其次ViewModel通过DOM Listener来监听DOM事件,并且通过methods中的操作,来改变obj中的数据。

有了Vue帮助我们完成VueModel层的任务,在后续的开发,我们就可以专注于数据的处理,以及DOM的编写工作了。

6、创建Vue实例传入的options

在创建Vue实例的时候,传入了一个对象options。那么,这个options中可以包含哪些选项呢?详细解析

  • el:

    传入类型:string | HTMLElement

    作用:决定之后Vue实例会管理哪一个DOM,挂载要管理的元素

    限制:只在用 new 创建实例时生效

  • data:

    类型:Object | Function (组件当中data必须是一个函数)

    作用:Vue实例对应的数据对象

    限制:组件的定义只接受 function

  • methods:

    类型:{ [key: string]: Function }

    作用:定义属于Vue的一些方法,可以在其他地方调用,也可以在指令中使用。

  • components:

    类型:Object

    详细:包含 Vue 实例可用组件的哈希表。

  • computed:

    类型:{ [key: string]: Function | { get: Function, set: Function } }

    详细:

    计算属性将被混入到 Vue 实例中。所有 getter 和 setter 的 this 上下文自动地绑定为 Vue 实例。

    注意如果你为一个计算属性使用了箭头函数,则 this 不会指向这个组件的实例,不过你仍然可以将其实例作为函数的第一个参数来访问。

    计算属性的结果会被缓存,除非依赖的响应式 property 变化才会重新计算。注意,如果某个依赖 (比如非响应式 property) 在该实例范畴之外,则计算属性是不会被更新的。

  • 生命周期函数:

    所有的生命周期钩子hook自动绑定 this 上下文到实例中,因此你可以访问数据,对 property 和方法进行运算。(粗体表示常用)

    • beforeCreate:

      类型:Function

      详细:

      在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。

    • created

      类型:Function

      详细:

      在实例创建完成后被立即调用。在这一步,实例已完成以下的配置:数据观测 (data observer),property 和方法的运算,watch/event 事件回调。然而,挂载阶段还没开始,$el property 目前尚不可用。

    • beforeMount:

      类型:Function

      详细:

      在挂载开始之前被调用:相关的 render 函数首次被调用。

      该钩子在服务器端渲染期间不被调用。

    • mounted

      类型:Function

      详细:

      实例被挂载后调用,这时 el 被新创建的 vm.$el 替换了。如果根实例挂载到了一个文档内的元素上,当 mounted 被调用时 vm.$el 也在文档内。

      注意 mounted 不会保证所有的子组件也都一起被挂载。如果你希望等到整个视图都渲染完毕,可以在 mounted 内部使用 vm.$nextTick

    • beforeUpdate:

      类型:Function

      详细:

      数据更新时调用,发生在虚拟 DOM 打补丁之前。这里适合在更新之前访问现有的 DOM,比如手动移除已添加的事件监听器。

      该钩子在服务器端渲染期间不被调用,因为只有初次渲染会在服务端进行。

    • updated:

      类型:Function

      详细:

      由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。

      当这个钩子被调用时,组件 DOM 已经更新,所以你现在可以执行依赖于 DOM 的操作。然而在大多数情况下,你应该避免在此期间更改状态。如果要相应状态改变,通常最好使用计算属性watcher 取而代之。

      注意 updated 不会保证所有的子组件也都一起被重绘。如果你希望等到整个视图都重绘完毕,可以在 updated 里使用 vm.$nextTick

    • activated

      类型:Function

      详细:

      被 keep-alive 缓存的组件激活时调用。

      该钩子在服务器端渲染期间不被调用。

    • deactivated:

      类型:Function

      详细:

      被 keep-alive 缓存的组件停用时调用。

      该钩子在服务器端渲染期间不被调用。

    • beforeDestroy:

      类型:Function

      详细:

      实例销毁之前调用。在这一步,实例仍然完全可用。

      该钩子在服务器端渲染期间不被调用。

    • destroyed:

      类型:Function

      详细:

      实例销毁后调用。该钩子被调用后,对应 Vue 实例的所有指令都被解绑,所有的事件监听器被移除,所有的子实例也都被销毁。

      该钩子在服务器端渲染期间不被调用。

    • errorCaptured:

      2.5.0+ 新增(具体查看)

      类型:(err: Error, vm: Component, info: string) => ?boolean

      详细:

      当捕获一个来自子孙组件的错误时被调用。此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。此钩子可以返回 false 以阻止该错误继续向上传播。

7、Vue的生命周期

以下图来自官网

Vue 实例生命周期

image-20210320025609038

简化:

image-20210320025717195

8、ES6补充

1、let/var

事实上var的设计可以看成JavaScript语言设计上的错误. 但是这种错误多半不能修复和移除, 以为需要向后兼容。于是,大概十年前,Brendan Eich就决定修复这个问题, 于是他添加了一个新的关键字: let。

我们可以将let看成更完美的var

1、块级作用域
  • JS中使用var来声明一个变量时, 变量的作用域主要是和函数的定义有关
  • 针对于其他块定义来说是没有作用域的,比如if/for等,这在我们开发中往往会引起一些问题。

我们可以通过ES6与ES5的不同来显示块级作用域的作用:

  • ES5中的var是没有块级作用域的(if/for),var只有在function中才有块级作用域。

    ES5之前因为if和for都没有块级作用域的概念,所以在很多时候,我们都必须借助于function的作用域来解决应用外面变量的问题。

  • ES6中的let是由块级作用的(if/for)

2、没有块级作用域引起的问题

for的块级:

1
2
3
4
5
6
var btns = document.getElementsByTagName('button');
for (var i=0; i<btns.length; i++) {
btns[i].addEventListener('click', function () {
console.log('第' + i + '个按钮被点击');
})
}

效果:无论点击哪个按钮,日志打印的都是第5个按钮被点击

image-20210320220031829

说明:由于var没有块级作用域,被var定义的i会随着i++的改变而改变。function里面的i受到for循环的i++的影响,被改变成了5,所以输出的都是第5个按钮被点击

3、解决方法:
  1. 用闭包可以解决问题。

    1
    2
    3
    4
    5
    6
    7
    8
    var btns = document.getElementsByTagName('button');
    for (var i=0; i<btns.length; i++) {
    (function (num) { // 0
    btns[i].addEventListener('click', function () {
    console.log('第' + num + '个按钮被点击');
    })
    })(i)
    }

    为什么闭包可以解决问题:函数是一个作用域。

  2. 用ES6的let

    1
    2
    3
    4
    5
    6
    const btns = document.getElementsByTagName('button')
    for (let i = 0; i < btns.length; i++) {
    btns[i].addEventListener('click', function () {
    console.log('第' + i + '个按钮被点击');
    })
    }

2、const

  • 在很多语言中已经存在, 比如C/C++中, 主要的作用是将某个变量修饰为常量。
  • 在JavaScript中也是如此, 使用const修饰的标识符为常量, 不可以再次赋值。

什么时候使用:

当我们修饰的标识符不会被再次赋值时, 就可以使用const来保证数据的安全性

建议:

在ES6开发中,优先使用const, 只有需要改变某一个标识符的时候才使用let。

使用const时要注意的点(以下代码为错误展示):

  • 一旦给const修饰的标识符被赋值之后, 不能修改

    1
    2
    const name = 'why';
    name = 'abc';
  • 在使用const定义标识符,必须进行赋值

    1
    const name;
  • 常量的含义是指向的对象不能修改, 但是可以改变对象内部的属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const obj = {
    name: 'why',
    age: 18,
    height: 1.88
    }

    // const修饰的标识符被赋值之后, 不能修改
    // obj = {}

    // 但是可以改变对象内部的属性
    obj.name = 'kobe';
    obj.age = 40;
    obj.height = 1.87;

3、对象增强写法

ES6中,对对象字面量进行了很多增强。

属性初始化简写和方法的简写:

  • 属性初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // ES5的写法
    const obj = {
    name: name,
    age: age,
    height: height
    }

    // ES6的写法
    const obj = {
    name,
    age,
    height,
    }
  • 方法的简写

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // ES5的写法
    const obj = {
    run: function () {

    },
    eat: function () {

    }
    }
    // ES6的写法
    const obj = {
    run() {

    },
    eat() {

    }
    }

二、Vue基础语法

1、语法糖

指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

语法糖对程序员来说是友好的,但对机器本身却不怎么好。语法糖越甜,编译成的二进制也就越麻烦,出错的时候也会带来更多的麻烦。程序员要做的不是尽力避免错误,而是聚焦在快速发现并改正错误。真正以快速方式轻易解决错误,“快速的失败”远胜过“预防错误”。

Vue中常用的语法糖:

  • v-bind:
  • v-on@
  • v-once.once

2、插值语法

1、mustache语法

Mustache(胡子/胡须)是一款「logic-less(轻逻辑)」的前端模板引擎,它原本是基于 javascript 实现的,但是因为轻量易用,所以经过拓展目前支持更多的平台,如 java,.NET,PHP,C++ 等。Mustache 主要用于在表现和数据相分离的前端技术架构中,根据数据生成特定的动态内容,这些内容在网页中指的是HTML结构,而在小程序中则是WXML结构。在前后端分离的技术架构下面,前端模板引擎是一种可以被考虑的技术选型,随着重型框架(AngularJS、ReactJS、Vue)的流行,前端的模板技术已经成为了某种形式上的标配,Mustache 的价值在于其稳定和经典

主页:https://github.com/janl/mustache.js/

文档:https://mustache.github.io/mustache.5.html

项目主页:http://mustache.github.io/

Handlebars:基于 Mustache 的模板引擎:http://handlebarsjs.com/

对于Vue简单来说:"{{}}"(双大括号)不仅仅可以直接写变量,也可以写简单的表达式 更多的Mustache功能参考:https://www.jianshu.com/p/7f1cecdc27e1 我们可以像下面这样来使用,并且数据是响应式的: ![image-20210320030738172](VUE/11.png) #### 2、v-once 在某些情况下,我们可能不希望界面随意的跟随改变,这个时候,我们就可以使用一个Vue的指令:v-once v-once: - 该指令后面不需要跟任何表达式(比如v-for后面是由跟表达式的) - p该指令表示元素和组件(组件后面才会学习)只渲染一次,不会随着数据的改变而改变。 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}</h2>
<h2 v-once>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>
效果: ![image-20210320032116662](VUE/12.png) #### 3、v-html 某些情况下,我们从服务器请求到的数据本身就是一个HTML代码。如果我们直接通过"{{}}"来输出,会将HTML代码也一起输出。但是我们可能希望的是按照HTML格式进行解析,并且显示对应的内容。这个时候,我们就可以使用一个Vue的指令:v-html

v-html:

  • 该指令后面往往会跟上一个string类型
  • 会将string的html解析出来并且进行渲染

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<h2>{{url}}</h2>
<h2 v-html="url"></h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
url: '<a href="http://www.baidu.com">百度一下</a>'
}
})
</script>

效果:

image-202103200324301694、v-text(不常用)

nv-text作用和Mustache比较相似:都是用于将数据显示在界面中

nv-text

  • 通常情况下,接受一个string类型

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}, 李银河!</h2>
<h2 v-text="message">, 李银河!</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>

效果:

image-20210320032749380

5、v-pre

v-pre用于跳过这个元素和它子元素的编译过程,用于显示原本的Mustache语法。

比如下面的代码:

  • 第一个h2元素中的内容会被编译解析出来对应的内容
  • 第二个h2元素中会直接显示

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<h2>{{message}}</h2>
<h2 v-pre>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello World'
}
})
</script>

效果:

image-20210320032946332

6、v-cloak

在某些情况下,我们浏览器可能会直接显然出未编译的Mustache标签(加载过慢)。

v-cloak

  • 存在期限:在vue解析之前存在,在vue解析之后消失。
  • 该指令后面不需要跟任何表达式

cloak:斗篷(起遮挡作用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
[v-cloak] {
display: none;
}
</style>
</head>
<body>

<div id="app" v-cloak>
<h2>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
// 在vue解析之前, div中有一个属性v-cloak
// 在vue解析之后, div中没有一个属性v-cloak
setTimeout(function () {
const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
}, 1000)
</script>

</body>
</html>

效果:

  • 在没加v-cloak之前,浏览器先显示,过1s后显示“你好啊”
  • 在加了v-cloak之后,浏览器先显示空白,过1s后显示“你好啊”

3、绑定属性(v-bind)

1、v-bind基础

前面的插值指令主要作用是将值插入到我们模板的内容当中。但是,除了内容需要动态来决定外,某些属性我们也希望动态来绑定。

  • 比如动态绑定a元素中网站的链接href
  • 比如动态绑定img元素的src属性
  • 动态绑定一些类、样式

v-bind指令:

  • 作用:绑定一个或多个属性值,或者向另一个组件传递props值
  • 缩写::
  • 预期:any (with argument) | Object (without argument)
  • 参数:attrOrProp (optional)

通过Vue实例中的data绑定元素的src和href:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div id="app">
<!-- 错误的做法: 这里不可以使用mustache语法-->
<!--<img src="{{imgURL}}" alt="">-->
<!-- 正确的做法: 使用v-bind指令 -->
<img v-bind:src="imgURL" alt="">
<a v-bind:href="aHref">百度一下</a>
<!--<h2>{{}}</h2>-->

<!--语法糖的写法-->
<img :src="imgURL" alt="">
<a :href="aHref">百度一下</a>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
imgURL: 'https://vuejs.org/images/log.png',
aHref: 'https://vuejs.org'
}
})
</script>

2、v-bind绑定class

很多时候,我们希望动态的来切换class:

  • 当数据为某个状态时,字体显示红色。
  • 当数据另一个状态时,字体显示黑色。
1、绑定方式:对象语法

对象语法的含义是:class后面跟的是一个对象。

语法:v-bind:class=’{类名: boolean,类名: boolean}’

eg:v-bind:class=”{类名1: true, 类名2: boolean}

对象语法有下面这些用法:

  • 直接通过{}绑定一个类:

    1
    <h2 :class="{'active': isActive}">Hello World</h2>
  • 通过判断,传入多个值:

    1
    <h2 :class="{'active': isActive, 'line': isLine}">Hello World</h2>
  • 和普通的类同时存在,并不冲突

    注:如果isActive和isLine都为true,那么会有title/active/line三个class

    1
    <h2 class="title" :class="{'active': isActive, 'line': isLine}">Hello World</h2>
  • 如果过于复杂,可以放在一个methods或者computed

    注:classes是一个计算属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <h2 class="title" :class="classes">Hello World</h2>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    isActive: true,
    isLine: true
    },
    computed: {
    classes: function () {
    return {active: this.isActive, line: this.isLine}
    }
    }
    })
2、绑定方式:数组语法

数组语法的含义是:class后面跟的是一个数组。

数组语法有下面这些用法:

  • 直接通过{}绑定一个类:

    1
    <h2 :class="['active','line']">Hello World</h2>
  • 和普通的类同时存在,并不冲突

    注:会有title/active/line三个class

    1
    <h2 class="title" :class=“[‘active’, 'line']">Hello World</h2>
  • 如果过于复杂,可以放在一个methods或者computed中

    注:classes是一个计算属性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <div id="app">
    <h2 class="title" :class="getClasses()">{{message}}</h2>
    </div>

    <script src="../js/vue.js"></script>
    <script>
    const app = new Vue({
    el: '#app',
    data: {
    active: 'aaaaaa',
    line: 'bbbbbbb'
    },
    methods: {
    getClasses: function () {
    return [this.active, this.line]
    }
    }
    })
    </script>

3、v-bind绑定style

我们可以利用v-bind:style来绑定一些CSS内联样式。

在写CSS属性名的时候,比如font-size

  • 我们可以使用驼峰式 (camelCase) fontSize
  • 或短横线分隔 (kebab-case,记得用单引号括起来) ‘font-size’
1、绑定方式:对象语法

style后面跟的是一个对象类型

  • 对象的key是CSS属性名称
  • 对象的value是具体赋的值,值可以来自于data中的属性
  • 如果过于复杂,可以放在一个methods或者computed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<style>
.title {
font-size: 50px;
color: red;
}
</style>
</head>
<body>

<div id="app">
<!--<h2 :style="{key(属性名): value(属性值)}">{{message}}</h2>-->

<!--'50px'必须加上单引号, 否则是当做一个变量去解析-->
<h2 :style="{fontSize: '50px'}">{{message}}</h2>

<!--finalSize当成一个变量使用-->
<!--<h2 :style="{fontSize: finalSize}">{{message}}</h2>-->
<h2 :style="{fontSize: finalSize + 'px', backgroundColor: finalColor}">{{message}}</h2>
<h2 :style="getStyles()">{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
finalSize: 100,
finalColor: 'red',
},
methods: {
getStyles: function () {
return {fontSize: this.finalSize + 'px', backgroundColor: this.finalColor}
}
}
})
</script>

</body>
</html>
2、绑定方式:数组语法

style后面跟的是一个数组类型

  • 多个值以,分割即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div id="app">
<h2 :style="[baseStyle, baseStyle1]">{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: '你好啊',
baseStyle: {backgroundColor: 'red'},
baseStyle1: {fontSize: '100px'},
}
})
</script>

4、计算属性(computed)

1、是什么计算属性

在模板中可以直接通过插值语法显示一些data中的数据。但是在某些情况,我们可能需要对数据进行一些转化后再显示,或者需要将多个数据结合起来进行显示:

  • 比如我们有firstName和lastName两个变量,我们需要显示完整的名称:

    • undefined undefined
  • 但是如果多个地方都需要显示完整的名称,我们就需要写多个

    。代码臃肿

我们可以将上面的代码换成计算属性:写在实例Vue的computed选项中的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<div id="app">
<h2>{{firstName + ' ' + lastName}}</h2>
<h2>{{firstName}} {{lastName}}</h2>

<h2>{{getFullName()}}</h2>

<h2>{{fullName}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
firstName: 'Lebron',
lastName: 'James'
},
// computed: 计算属性()
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
},
methods: {
getFullName() {
return this.firstName + ' ' + this.lastName
}
}
})
</script>

2、计算属性:

  • 解决代码臃肿

  • 可以进行一些更加复杂的操作

    image-20210320150614370

  • 计算属性会进行缓存,如果多次使用时,计算属性只会调用一次。

3、计算属性的setter和getter

每个计算属性都包含一个getter和一个setter

  • 在上面的例子中,我们只是使用getter来读取。

  • 在某些情况下,你也可以提供一个setter方法(不常用)。

    由于一般我们不希望有人能任意修改我们的计算属性的值,所以一般省略setter方法。而计算属性的getter就能简写成:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 简写前(如果有setter方法):
    computed: {
    fullName: {
    get() {
    console.log('---调用了fullName的get');
    return this.firstName + ' ' + this.lastName
    }
    },
    set(newValue) {
    console.log('---调用了fullName的get');
    const names = newValue.split(' ');
    this.firstName = names[0];
    this.lastName = names[1];
    }
    }

    // 简写后:
    computed: {
    fullName: function () {
    return this.firstName + ' ' + this.lastName
    }
    },

4、methods与computed

methods和computed看起来都可以实现我们的功能,那么为什么还要多一个计算属性这个东西呢?

原因:计算属性会进行缓存,如果多次使用时,计算属性只会调用一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<div id="app">
<!--1.直接拼接: 语法过于繁琐-->
<h2>{{firstName}} {{lastName}}</h2>

<!--2.通过定义methods-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->
<!--<h2>{{getFullName()}}</h2>-->

<!--3.通过computed-->
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
<h2>{{fullName}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
// angular -> google
// TypeScript(microsoft) -> ts(类型检测)
// flow(facebook) ->
const app = new Vue({
el: '#app',
data: {
firstName: 'Kobe',
lastName: 'Bryant'
},
methods: {
getFullName: function () {
console.log('getFullName');
return this.firstName + ' ' + this.lastName
}
},
computed: {
fullName: function () {
console.log('fullName');
return this.firstName + ' ' + this.lastName
}
}
})

</script>

效果:

  • 当使用computed时:由于有缓存,浏览器只执行了一次。

    image-20210320153300912

  • 当使用methods时:没有缓存,浏览器执行多次,加重了浏览器的负担。

    image-20210320153616146

5、事件监听(v-on)

在前端开发中,我们需要经常和用于交互。

这个时候,我们就必须监听用户发生的时间,比如点击、拖拽、键盘事件等等

在Vue中如何监听事件呢?使用v-on指令

v-on:

  • 作用:绑定事件监听器
  • 缩写(语法糖):@
  • 预期:Function | Inline Statement | Object
  • 参数:event

1、v-on基础

  • 一般v-on后面加上:,然后加上动作如点击(click)、拖拽、键盘事件(keyup/keydown)等等。

  • 若v-on监听的事件简单,可以在v-on后面直接实现

    1
    <button v-on:click="counter++">+</button>
  • 若v-on监听的事件复杂,就需要将事件的实现抽取成一个方法

    1
    2
    3
    4
    5
    6
    7
    <button v-on:click="increment">+</button>

    methods: {
    increment() {
    this.counter++
    }
    }

2、v-on参数

当通过methods中定义方法,以供@click调用时,需要注意参数问题

  • 如果该方法不需要额外参数,那么方法后的()可以不添加。

    但是注意:如果方法本身中有一个参数,那么会默认将原生事件event参数传递进去。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!--1.事件调用的方法没有参数-->
    <!--1.1函数后添加()-->
    <button @click="btn1Click()">按钮1</button>
    <!--1.1函数后不添加()-->
    <button @click="btn1Click">按钮1</button>

    methods: {
    btn1Click() {
    console.log("btn1Click");
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <!--2.在事件定义时, 写方法时省略了小括号, 但是方法本身是需要一个参数的, 这个时候, Vue会默认将浏览器生产的event事件对象作为参数传入到方法-->
    <!--2.1函数后没添加()-->
    <button @click="btn2Click">按钮2</button>

    <!--2.2函数需要参数,()里传入参数-->
    <!--<button @click="btn2Click(123)">按钮2</button>-->

    <!--2.3如果函数需要参数,但是没有传入, 那么函数的形参为undefined-->
    <!--<button @click="btn2Click()">按钮2</button>-->

    methods: {
    btn2Click(event) {
    console.log('--------', event);
    }
    }

    2.1的效果:

    image-20210320232959026

    2.3的效果:

    image-20210320233429354

  • 如果需要同时传入某个参数,同时需要event时,可以通过$event传入事件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!--3.方法定义时, 我们需要event对象, 同时又需要其他参数-->
    <!--3.1在调用函数时, 如何手动的获取到浏览器参数的event对象: $event-->
    <button @click="btn3Click('abc', $event)">按钮3</button>
    <!--3.2在调用函数时,若event没有加$,那么浏览器将默认将event当成一个变量,若event在app实例里没有定义的话,浏览器会找不到该变量而报错并且返回undefined-->
    <button @click="btn3Click('abc', event)">按钮3</button>
    <!--3.3在调用函数时,若函数没传入参数,那么浏览器将默认将浏览器参数的event放入第一个参数中,又因为第二个参数没有传值,浏览器会将其变为undefined-->
    <button @click="btn3Click">按钮3</button>

    methods: {
    btn3Click(abc, event) {
    console.log('++++++++', abc, event);
    }
    }

    3.1的效果:

    image-20210320234009069

    3.2的效果:

    image-20210320235528249

    3.3的效果:

    image-20210320235715797

3、v-on修饰符

在某些情况下,我们拿到event的目的可能是进行一些事件处理。Vue提供了修饰符来帮助我们方便的处理一些事件:

  • .stop - 调用 event.stopPropagation()。
  • .prevent - 调用 event.preventDefault()。
  • .{keyCode | keyAlias} - 只当事件是从特定键触发时才触发回调。
  • .native - 监听组件根元素的原生事件。
  • .once - 只触发一次回调。

image-20210321000423484

更多修饰符参考官网的事件修饰符,以下来自官网。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- 阻止单击事件继续传播 -->
<a v-on:click.stop="doThis"></a>

<!-- 提交事件不再重载页面 -->
<form v-on:submit.prevent="onSubmit"></form>

<!-- 修饰符可以串联 -->
<a v-on:click.stop.prevent="doThat"></a>

<!-- 只有修饰符 -->
<form v-on:submit.prevent></form>

<!-- 添加事件监听器时使用事件捕获模式 -->
<!-- 即内部元素触发的事件先在此处理,然后才交由内部元素进行处理 -->
<div v-on:click.capture="doThis">...</div>

<!-- 只当在 event.target 是当前元素自身时触发处理函数 -->
<!-- 即事件不是从内部元素触发的 -->
<div v-on:click.self="doThat">...</div>

<!-- 点击事件将只会触发一次 -->
<a v-on:click.once="doThis"></a>

<!-- 滚动事件的默认行为 (即滚动行为) 将会立即触发 -->
<!-- 而不会等待 `onScroll` 完成 -->
<!-- 这其中包含 `event.preventDefault()` 的情况 -->
<div v-on:scroll.passive="onScroll">...</div>

<!-- 只有在 `key` 是 `Enter` 时调用 `vm.submit()` -->
<input v-on:keyup.enter="submit">

<input v-on:keyup.page-down="onPageDown">

6、条件判断

1、v-if、v-else-if、v-else

  • 这三个指令与JavaScript的条件语句if、else、else if类似。

  • Vue的条件指令可以根据表达式的值在DOM中渲染或销毁元素或组件。

简单的案例演示:

image-20210321004504280

image-20210321005848254

v-if的原理:

  • v-if后面的条件为false时,对应的元素以及其子元素不会渲染。

  • 也就是根本没有不会有对应的标签出现在DOM中。

2、一个简单的小案例(用户登陆方式切换)

用户再登录时,可以切换使用用户账号登录还是邮箱地址登录。类似如下情景:

image-20210321013857218

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>

<div id="app">
<span v-if="isUser">
<label for="username">用户账号</label>
<input type="text" id="username" placeholder="用户账号">
</span>
<span v-else>
<label for="email">用户邮箱</label>
<input type="text" id="email" placeholder="用户邮箱">
</span>
<button @click="isUser = !isUser">切换类型</button>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
isUser: true
}
})
</script>

</body>
</html>
1、问题

以上案例会有一个小问题:如果我们在有输入内容的情况下,切换了类型,我们会发现文字依然显示之前的输入的内容。

为什么呢?按道理讲,我们在一个input输入的内容(value),在切换到另外一个input元素后应该消失,因为在另一个input元素中,我们并没有输入内容。

2、问题解答
  • 这是因为Vue在进行渲染时,不会直接渲染在浏览器上面,Vue会在其之间构建一个虚拟NOM,Vue会先渲染在虚拟DOM上面,然后在渲染在浏览器上。而出于性能考虑,当出现两个只存在一个(if -else)的时候,会尽可能的复用已经存在的元素,而不是重新创建新的元素。
  • 在上面的案例中,Vue内部会发现原来(if)的input元素不再使用,直接作为else中的input来使用了。此时并不会重新构建一个input,并且改变的只有与之前input不同的内容(如for、id、placeholder等等),所以文本里面的内容不会改变。
3、解决方案

如果我们不希望Vue出现类似重复利用的问题,可以给对应的input添加key

并且需要我们保证key的值不同。(若key的值相同的话还是会继承文本内容)

1
2
3
4
5
6
7
8
<span v-if="isUser">
<label for="username">用户账号</label>
<input type="text" id="username" placeholder="用户账号" key="username">
</span>
<span v-else>
<label for="email">用户邮箱</label>
<input type="text" id="email" placeholder="用户邮箱" key="email">
</span>

3、v-show

v-show的用法和v-if非常相似,也用于决定一个元素是否渲染

v-if和v-show对比:

  • v-if: 当条件为false时, 包含v-if指令的元素, 根本就不会存在dom中
  • v-show: 当条件为false时, v-show只是给我们的元素添加一个行内样式: display: none

v-if和v-show都可以决定一个元素是否渲染,开发中如何选择呢:

  • 当需要在显示与隐藏之间切换很频繁时,使用v-show
  • 当只有一次切换时,通过使用v-if

7、循环遍历(v-for)

1、v-for遍历数组

当我们有一组数据需要进行渲染时,我们就可以使用v-for来完成

  • v-for的语法类似于JavaScript中的for循环。

  • 格式如下:item in items的形式。

  • 如果在遍历的过程中不需要使用索引值

    1
    2
    3
    <ul>
    <li v-for="item in names">{{item}}</li>
    </ul>
  • 如果在遍历的过程中,我们需要拿到元素在数组中的索引值

    1
    2
    3
    4
    5
    6
    <ul>
    <li v-for="(item, index) in names">
    // 使遍历从1开始
    {{index+1}}.{{item}}
    </li>
    </ul>

2、v-for遍历对象

当有一对象需要我们对其里面的数据进行渲染时,我们就可以使用v-for来完成

  • 在遍历对象的过程中, 如果只是获取一个值, 那么获取到的是value

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="item in info">{{item}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why
    • 18
    • 1.88
  • 获取key和value 格式: (value, key)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="(value, key) in info">{{value}}-{{key}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why-name
    • 18-age
    • 1.88-height
  • 获取key和value和index 格式: (value, key, index)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <ul>
    <li v-for="(value, key, index) in info">{{value}}-{{key}}-{{index + 1}}</li>
    </ul>

    <script>
    const app = new Vue({
    el: '#app',
    data: {
    info: {
    name: 'why',
    age: 18,
    height: 1.88
    }
    }
    })
    </script>

    效果:

    • why-name-1
    • 18-age-2
    • 1.88-height-3

3、v-for的组件的key属性

官方推荐我们在使用v-for时,给对应的元素或组件添加上一个:key属性。

为什么需要这个key属性呢?

  • 这个其实和Vue的虚拟DOM的Diff算法有关系

  • 我们借用React’s diff algorithm中的一张图来简单说明一下:

    image-20210321153710582

  • 当某一层有很多相同的节点时,也就是列表节点时,我们希望插入一个新的节点

    • 我们希望可以在B和C之间加一个F,Diff算法默认执行起来是这样的
    • 即把C更新成F,D更新成C,E更新成D,最后再插入E
  • 这样做会使程序的执行效率变低,所以可以用key这个属性来给每个节点做一个唯一标识

    • Diff算法就可以正确的识别此节点
    • 找到正确的位置区插入新的节点
  • key的作用主要是为了高效的更新虚拟DOM

4、检测数组更新(响应式)

因为Vue是响应式的,所以当数据发生变化时,Vue会自动检测数据变化,视图会发生对应的更新。

Vue中包含了一组观察数组编译的方法,使用它们改变数组也会触发视图的更新:

  • push():在数组末尾添加一个或多个元素

    1
    2
    3
    4
    5
    6
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.push('aaa')
    this.letters.push('aaaa', 'bbbb', 'cccc')
  • pop():删除数组中的最后一个元素

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.pop();
  • shift():删除数组中的第一个元素

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.shift();
  • unshift():在数组最前面添加一个或多个元素

    1
    2
    3
    4
    5
    6
    data: {
    letters: ['a', 'b', 'c', 'd']
    }

    this.letters.unshift()
    this.letters.unshift('aaa', 'bbb', 'ccc')
  • splice(start,index,…items):删除元素/插入元素/替换元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    // 删除元素: 第二个参数传入你要删除几个元素(如果没有传,就删除后面所有的元素)
    this.letters.splice(1, 3)
    this.letters.splice(1)

    // 替换元素: 第二个参数, 表示我们要替换几个元素, 后面是用于替换前面的元素
    this.letters.splice(1, 3, 'm', 'n', 'l', 'x')

    // 插入元素: 第二个参数, 传入0, 并且后面跟上要插入的元素
    this.letters.splice(1, 0, 'x', 'y', 'z')
  • sort():对数组进行排序。(参数可以添加排序的规则的方法)

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    this.letters.sort()
  • reverse():

    1
    2
    3
    4
    5
    data: {
    letters: ['a', 'd', 'c', 'b']
    }

    this.letters.reverse()

注意:通过索引值修改数组中的元素不能做到响应式

1
2
3
4
5
data: {
letters: ['a', 'd', 'c', 'b']
}

this.letters[0] = 'bbbbbb';

此时可以通过splice方法或Vue的set方法的方式来修改以达到响应式的目的

1
2
3
4
5
// splice方法
this.letters.splice(0, 1, 'bbbbbb')

// Vue的set(要修改的对象, 索引值, 修改后的值)
Vue.set(this.letters, 0, 'bbbbbb')

5、作业:(v-for + v-bind + v-on + 当前索引方法的应用)

需求:有一电影列表,点击哪一部影片,哪一部影片就表现为红色。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>homework</title>
</head>
<style>
.active{
color: red;
}
</style>
<body>

<div id = "app">
<ul>
<li v-for="(movie,index) in movies"
:class="{active: currentIndex === index}"
@click="isClick(index)">
{{index}}.{{movie}}
</li>
</ul>
</div>
<script src="../js/vue.js"></script>

<script>

const app = new Vue({
el: '#app',
data: {
movies: ['海贼王','火影忍者','进击的巨人','妖精的尾巴'],
currentIndex: -1
},
methods: {
isClick: function (index) {
this.currentIndex = index
}
}
})

</script>

</body>
</html>

6、高阶函数filter|map|reduce

1、filter:过滤作用

filter函数的参数是一个回调函数,返回值为一个数组:

  • 回调函数的参数为循环遍历的值n
  • 回调函数有一个要求: 必须返回一个boolean值
    • true: 当返回true时, 函数内部会自动将这次回调的n加入到新的数组中
    • false:当返回false时, 函数内部会过滤掉这次的n

使用:

1
2
3
4
5
6
const nums = [10, 20, 111, 222, 444, 40, 50]

// newNums = [10,20,40,50]
let newNums = nums.filter(function (n) {
return n < 100
})
2、map:映射作用

map函数的参数是一个回调函数,返回值为一个数组:

  • 回调函数的参数为循环遍历的值n
  • 可以在回调函数内对数组的值进行操作,map会帮操作完的值映射到一个新的数组

使用:

1
2
3
4
5
// newNums = [10,20,40,50]
// new2Nums = [20,40,80,100]
let new2Nums = newNums.map(function (n) { // 20
return n * 2
})
3、reduce:作用对数组中所有的内容进行汇总

map函数的参数是一个回调函数,和一个初始值

  • 回调函数有两个参数(previousValue,start)
    • previousValue:数组当前值的前一个值
    • start:数组当前值
  • 初始值为数组一开始值(第一个元素,index=0)的前一个值
1
2
3
4
5
// new2Nums = [20,40,80,100]
// total = 240
let total = new2Nums.reduce(function (preValue, n) {
return preValue + n
}, 0)
4、总结

需求:筛选出数组nums里所有小于100的值,然后就值乘以2再相加。

1
2
3
4
5
6
7
8
9
const nums = [10, 20, 111, 222, 444, 40, 50]

let total = nums.filter(function (n) {
return n < 100
}).map(function (n) {
return n * 2
}).reduce(function (prevValue, n) {
return prevValue + n
}, 0)

以上三个高阶函数的回调函数都可以用箭头函数表示。

1
let total = nums.filter(n => n < 100).map(n => n * 2).reduce((pre, n) => pre + n)

8、表单绑定(v-mode)

1、v-mode基础

表单控件在实际开发中是非常常见的。特别是对于用户信息的提交,需要大量的表单。

Vue中使用v-model指令来实现表单元素和数据的双向绑定。

案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model="message">
{{message}}
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>

image-20210321210459660

案例的解析:

当我们在输入框输入内容时,因为input中的v-model绑定了message,所以会实时将输入的内容传递给message,message发生改变。当message发生改变时,因为上面我们使用Mustache语法,将message的值插入到DOM中,所以DOM会发生响应的改变。所以,通过v-model实现了双向的绑定。

当然,我们也可以将v-model用于textarea元素。

2、v-mode的原理

v-model其实是一个语法糖,它的背后本质上是包含两个操作:

  • v-bind绑定一个value属性
  • v-on指令给当前元素绑定input事件
1
2
3
4
5
6
7
8
9
10
<input type="text" v-model="message">
<!--等同与-->
<input type="text" :value="message" @input="valueChange">
methods: {
valueChange(event) {
this.message = event.target.value;
}
}
<!--也等同与-->
<input type="text" v-bind:value="message" v-on:input="message = $event.target.value">

3、v-mode:radio

当存在多个单选框时,v-mode可用于将单选框的值和与之对应变量进行双向绑定。

其中一个label与一个input组合,label里面的for与input里面的id一一对应,实现用户点击文字就可以选中对应的单选框。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
<label for="male">
<input type="radio" id="male" value="男" v-model="sex">
</label>
<label for="female">
<input type="radio" id="female" value="女" v-model="sex">
</label>
<h2>您选择的性别是: {{sex}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
sex: '女'
}
})
</script>

4、v-mode:checkbox

checkbox复选框分为两种情况:单个勾选框和多个勾选框

  • 单个勾选框:

    • v-model即为布尔值

    • 此时input的value并不影响v-model的值

    • 常用于让用户点击同意协议后才能点击下一步的业务场景

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      <div id="app">
      <label for="licence">-->
      <input type="checkbox" id="licence" v-model="isAgree">同意协议
      </label>
      <h2>您选择的是: {{isAgree}}</h2>
      <button :disabled="!isAgree">下一步</button>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      isAgree: false,
      }
      })
      </script>
  • 多个复选框

    • 当是多个复选框时,因为可以选中多个,所以对应的data中属性是一个数组

    • 当选中某一个时,就会将input的value添加到数组中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      <div id="app">
      <input type="checkbox" value="篮球" v-model="hobbies">篮球
      <input type="checkbox" value="足球" v-model="hobbies">足球
      <input type="checkbox" value="乒乓球" v-model="hobbies">乒乓球
      <input type="checkbox" value="羽毛球" v-model="hobbies">羽毛球
      <h2>您的爱好是: {{hobbies}}</h2>

      <!--label中的:for与input的:id对应-->
      <label v-for="item in originHobbies" :for="item">
      <input type="checkbox" :value="item" :id="item" v-model="hobbies">{{item}}
      </label>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      hobbies: [],
      originHobbies: ['篮球', '足球', '乒乓球', '羽毛球', '台球', '高尔夫球']
      }
      })
      </script>

5、v-mode:select

select也分单选和多选两种情况:

  • 单选:只能选中一个值:

    • v-model绑定的是一个值

    • 当我们选中option中的一个时,会将它对应的value赋值到mySelect中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      <div id="app">
      <select name="abc" v-model="fruit">
      <option value="苹果">苹果</option>
      <option value="香蕉">香蕉</option>
      <option value="榴莲">榴莲</option>
      <option value="葡萄">葡萄</option>
      </select>
      <h2>您选择的水果是: {{fruit}}</h2>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      // 默认香蕉
      fruit: '香蕉'
      }
      })
      </script>
  • 多选:可以选中多个值(属性加上multiple):

    • v-model绑定的是一个数组

    • 当选中多个值时,就会将选中的option对应的value添加到数组mySelects中

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      <div id="app">
      <select name="abc" v-model="fruits" multiple>
      <option value="苹果">苹果</option>
      <option value="香蕉">香蕉</option>
      <option value="榴莲">榴莲</option>
      <option value="葡萄">葡萄</option>
      </select>
      <h2>您选择的水果是: {{fruits}}</h2>
      </div>

      <script src="../js/vue.js"></script>
      <script>
      const app = new Vue({
      el: '#app',
      data: {
      fruits: []
      }
      })
      </script>

6、值绑定

动态的给value赋值而已。我们前面的value中的值,都是在定义input的时候直接给定的(写死),但是真实开发中,这些input的值可能是从网络获取或定义在data中的。所以我们可以通过v-bind:value动态的给value绑定值(其实就是v-bind在input中的应用)

7、修饰符

1、lazy修饰符

默认情况下,v-model默认是在input事件中同步输入框的数据的。也就是说,一旦有数据发生改变对应的data中的数据就会自动发生改变。

lazy修饰符可以让数据在失去焦点或者回车时才会更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model.lazy="message">
<h2>{{message}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
message: 'Hello Vue'
}
})
</script>

效果:

  • 聚焦时:

image-20210321214116893

  • 失焦时:

image-20210321214156460

2、number修饰符

默认情况下,在输入框中无论我们输入的是字母还是数字,都会被当做字符串类型进行处理。但是如果我们希望处理的是数字类型,那么最好直接将内容当做数字处理。

number修饰符可以让在输入框中输入的内容自动转成数字类型

1
2
3
4
5
6
7
8
9
10
11
12
13
<div id="app">
<input type="number" v-model.number="age">
<h2>{{age}}-{{typeof age}}</h2>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
age: 18
}
})
</script>

效果:

  • 没加number:

    image-20210321214845631

  • 加上number:

    image-20210321214942404

3、trim修饰符

如果输入的内容首尾有很多空格,通常我们希望将其去除,trim修饰符可以过滤内容左右两边的空格(浏览器会格式化显示时帮忙去掉多余的空格,但在代码里空格依旧存在,trim修饰符可以过滤内容左右两边的空格)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div id="app">
<input type="text" v-model.trim="name">
<h2>您输入的名字:{{name}}</h2>
</div>

<script src="../js/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
name: ''
}
})
</script>

效果:

  • 没加trim:

    image-20210321215801147

  • 加上trim

    image-20210321215539094

三、组件化开发

1、什么是组件化

如果我们将一个页面中所有的处理逻辑全部放在一起,处理起来就会变得非常复杂,而且不利于后续的管理以及扩展。但如果,我们讲一个页面拆分成一个个小的功能块,每个功能块完成属于自己这部分独立的功能,那么之后整个页面的管理和维护就变得非常容易了。

如图:

  • 我们将一个完整的页面分成很多个组件。
  • 每个组件都用于实现页面的一个功能块。
  • 而每一个组件又可以进行细分。

image-20210321224044742

2、Vue组件化思想

组件化是Vue.js中的重要思想。它提供了一种抽象,让我们可以开发出一个个独立可复用的小组件来构造我们的应用。任何的应用都会被抽象成一颗组件树。

image-20210321224237074

组件化思想的应用:

  • 有了组件化的思想,我们在之后的开发中就要充分的利用它。
  • 尽可能的将页面拆分成一个个小的、可复用的组件。
  • 这样让我们的代码更加方便组织和管理,并且扩展性也更强。

注意:每一个组件都有独属于自己的data、methodscomputedcomponentstemplate等等。其中app可以看成所有组件的根组件(root)。但要注意,app也只能调用自己的儿子组件,不能去跨辈调用孙子组件。

3、注册组件

组件的使用分成三个步骤:

  1. 创建组件构造器
  2. 注册组件
  3. 使用组件:组件只能在注册过的实例里使用,否则Vue因没有进行管理不会加载组件。

注意:字符串的表达除了有''(单引号)、""(双引号)以外,还有``(尖引号)。尖引号可以实现字符串的跨行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<div id="app">
<!--3.使用组件-->
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>
<my-cpn></my-cpn>

<div>
<div>
<my-cpn></my-cpn>
</div>
</div>
</div>

<!--以下没有在app实例里使用,Vue没有进行管理不会加载组件-->
<my-cpn></my-cpn>

<script src="../js/vue.js"></script>
<script>
// 1.创建组件构造器对象
const cpnC = Vue.extend({
template: `
<div>
<h2>组件标题</h2>
<p>我是组件中的一个段落内容</p>
</div>`
})

// 2.注册组件
// 参数1:组件的名称,参数2:组件构造器对象的名称
Vue.component('my-cpn', cpnC)

const app = new Vue({
el: '#app',
data: {
message: '你好啊'
}
})
</script>

注册组件步骤解析

  1. Vue.extend():

    1. 调用Vue.extend()创建的是一个组件构造器;
    2. 通常在创建组件构造器时,传入template代表我们自定义组件的模板;
    3. 该模板就是在使用到组件的地方,要显示的HTML代码;
    4. 事实上,这种写法在Vue2.x的文档中几乎已经看不到了,它会直接使用下面我们会讲到的语法糖,但是在很多资料还是会提到这种方式,而且这种方式是学习后面方式的基础
  2. Vue.component():

    1. 调用Vue.component()是将刚才的组件构造器注册为一个组件,并且给它起一个组件的标签名称;
    2. 所以需要传递两个参数:
      1. 注册组件的标签名
      2. 组件构造器
  3. 组件必须挂载在某个Vue实例下,否则它不会生效:

    1. 我们来看下面我使用了三次

    2. 而第三次其实并没有生效:

      image-20210322023308247

4、全局组件和局部组件

当我们通过调用Vue.component()注册组件时,组件的注册是全局的

这意味着该组件可以在任意Vue示例下使用。

image-20210322023704676

如果我们注册的组件是挂载在某个实例中, 那么就是一个局部组件

image-20210322023725407

5、父组件和子组件

在前面我们看到了组件树:组件和组件之间存在层级关系。而其中一种非常重要的关系就是父子组件的关系

我们来看通过代码如何组成的这种层级关系:

image-20210322024427807

父子组件错误用法:以子标签的形式在Vue实例中使用

  • 因为当子组件注册到父组件的components时,Vue会编译好父组件的模块
  • 该模板的内容已经决定了父组件将要渲染的HTML(相当于父组件中已经有了子组件中的内容了)
  • 是只能在父组件中被识别的。
  • 类似这种用法,是会被浏览器忽略的。

6、注册组件语法糖

在上面注册组件的方式,可能会有些繁琐。Vue为了简化这个过程,提供了注册的语法糖。

主要是省去了调用Vue.extend()的步骤,而是可以直接使用一个对象来代替。

语法糖注册全局组件和局部组件:

  • 全局组件的语法糖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <div id="app">
    <cpn1></cpn1>
    </div>

    <script src="../js/vue.js"></script>

    <script>
    Vue.component('cpn1', {
    template: `
    <div>
    <h2>我是标题1</h2>
    <p>我是内容, 哈哈哈哈</p>
    </div>
    `
    })

    const app = new Vue({
    el: '#app'
    }
    })
    </script>
  • 局部组件的语法糖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <div id="app">
    <cpn2></cpn2>
    </div>

    <script src="../js/vue.js"></script>
    <script>
    // 注册局部组件的语法糖
    const app = new Vue({
    el: '#app',
    components: {
    'cpn2': {
    template: `
    <div>
    <h2>我是标题2</h2>
    <p>我是内容, 呵呵呵</p>
    </div>
    `
    }
    }
    })
    </script>

7、模板的分离写法

以上代码虽然通过语法糖简化了Vue组件的注册过程,但还有一个地方的写法比较麻烦,就是template模块写法。如果我们能将其中的HTML分离出来写,然后挂载到对应的组件上,必然结构会变得非常清晰。

Vue提供了两种方案来定义HTML模块内容:

  • 使用

23种设计模式


[TOC]

1、引子

1、设计模式采用的七大原则:

  • 单一职责原则

  • 接口隔离原则

  • 依赖倒转原则

  • 里氏替换原则

  • 开闭原则(ocp)

    • 工厂模式

      image-20210410201229604

  • 迪米特原则

  • 合成复用原则

单例设计模式一共有 8 种写法:

  • 饿汉式 两种
  • 懒汉式 三种
  • 双重检查
  • 静态内部类
  • 枚举

2、设计模式的重要性

  • 软件工程中,设计模式(design pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在 1990 年代从建筑设计领域引入到计算机科学的
  • 拿实际工作经历来说, 当一个项目开发完后,如果客户提出增新功能,怎么办?(可扩展性,使用设计模式,软件具有很好的扩展性)
  • 如果项目开发完后,原来程序员离职,你接手维护该项目怎么办? (维护性[可读性、规范性])
  • 目前程序员门槛越来越高,一线IT公司(大厂),都会问你在实际项目中使用过什么设计模式,怎样使用的,解决了什么问题
  • 设计模式在软件中哪里?面向对象(oo)=>功能模块[设计模式+算法(数据结构)]=>框架[使用到多种设计模式]=> 架构 [服务器集群]
  • 如果想成为合格软件工程师,那就花时间来研究下设计模式是非常必要的.

3、设计模式的讲解过程

讲解的步骤

  1. 应用场景
  2. 普通代码解决
  3. 设计模式解决【对比】
  4. 剖析原理
  5. 分析实现步骤(图解)
  6. 代码实现
  7. 框架或项目源码分析(找到使用的地方) 的步骤讲解

2、设计模式七大原则(单接依里开迪合)

2.1、设计模式的目的

编写软件过程中,程序员面临着来自 耦合性,内聚性以及可维护性,可扩展性,重用性,灵活性 等多方面的挑战,设计模式是为了让程序(软件),具有更好的:

  1. 代码重用性 (即:相同功能的代码,不用多次编写)
  2. 可读性 (即:编程规范性, 便于其他程序员的阅读和理解)
  3. 可扩展性 (即:当需要增加新的功能时,非常的方便,称为可维护)
  4. 可靠性 (即:当我们增加新的功能后,对原来的功能没有影响)
  5. 使程序呈现高内聚,低耦合的特性

2.2 、设计模式七大原则

设计模式原则,其实就是程序员在编程时,应当遵守的原则,也是各种设计模式的基础(即:设计模式为什么这样设计的依据)

设计模式常用的七大原则有:

  1. 单一职责原则
  2. 接口隔离原则
  3. 依赖倒转(倒置)原则
  4. 里氏替换原则
  5. 开闭原则
  6. 迪米特法则
  7. 合成复用原则

2.3、单一职责原则(Single Responsibility Principle)

2.3.1、基本介绍

单一职责的含义是:类的职责单一,引起类变化的原因单一。对类来说的,即一个类应该只负责一项职责。解释一下,这也是灵活的前提,如果我们把类拆分成最小的职能单位,那组合与复用就简单的多了,如果一个类做的事情太多,在组合的时候,必然会产生不必要的方法出现,这实际上是一种污染。

如类 A 负责两个不同职责:职责 1,职责 2。当职责 1 需求变更而改变 A 时,可能造成职责 2 执行错误,所以需要将类 A 的粒度分解为 A1,A2。

SRP优点:消除耦合,减小因需求变化引起代码僵化。

2.3.2、应用实例

需求:以交通工具案例讲解(海陆空)

方案 1 [分析说明]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SingleResponsibility1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
Vehicle vehicle = new Vehicle();
vehicle.run("摩托车");
vehicle.run("汽车");
vehicle.run("飞机");
}

// 交通工具类
// 方式 1
// 1. 在方式 1 的 run 方法中,违反了单一职责原则
// 2. 解决的方案非常的简单,根据交通工具运行方法不同,分解成不同类即可
class Vehicle {
public void run(String vehicle) {
System.out.println(vehicle + " 在公路上运行....");
}
}
}

方案 2 [分析说明]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class SingleResponsibility1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
RoadVehicle roadVehicle = new RoadVehicle();
roadVehicle.run("摩托车");
roadVehicle.run("汽车");

AirVehicle airVehicle = new AirVehicle();
airVehicle.run("飞机");
}

//方案 2
//1. 遵守单一职责原则
//2. 但是这样做的改动很大,即将类分解,同时修改客户端
//3. 改进:直接修改 Vehicle 类,改动的代码会比较少=>方案 3
class RoadVehicle {
public void run(String vehicle) {
System.out.println(vehicle + "公路运行");
}
}

class AirVehicle {
public void run(String vehicle) {
System.out.println(vehicle + "天空运行");
}
}

class WaterVehicle {
public void run(String vehicle) {
System.out.println(vehicle + "水中运行");
}
}
}

方案 3 [分析说明]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class SingleResponsibility3 {
public static void main(String[] args) {
// TODO Auto-generated method stub Vehicle2 vehicle2 = new Vehicle2();
vehicle2.run("汽车");
vehicle2.runWater("轮船");
vehicle2.runAir("飞机");
}

//方式 3
//1. 这种修改方法没有对原来的类做大的修改,只是增加方法
//2. 这里虽然没有在类这个级别上遵守单一职责原则,但是在方法级别上,仍然是遵守单一职责
class Vehicle2 {
public void run(String vehicle) {
//处理
System.out.println(vehicle + " 在公路上运行....");
}

public void runAir(String vehicle) {
System.out.println(vehicle + " 在天空上运行....");
}

public void runWater(String vehicle) {
System.out.println(vehicle + " 在水中行....");
}
}
}

2.3.3、单一职责原则注意事项和细节

  1. 降低类的复杂度,一个类只负责一项职责。
  2. 提高类的可读性,可维护性
  3. 降低变更引起的风险
  4. 在实际编码的过程中很难将它恰当地运用,需要结合实际情况进行运用。
  5. 通常情况下,我们应当遵守单一职责原则,只有逻辑足够简单,才可以在代码级违反单一职责原则;只有类中方法数量足够少,可以在方法级别保持单一职责原则

2.4 、接口隔离原则(Interface Segregation Principle)

2.4.1、基本介绍

  1. 它的含义是尽量使用职能单一的接口,而不使用职能复杂、全面的接口。

  2. 接口是为了让子类实现的,如果子类想达到职能单一,那么接口也必须满足职能单一。 相反,如果接口融合了多个不相关的方法,那它的子类就被迫要实现所有方法,尽管有些方法是根本用不到的。这就是接口污染。

  3. 客户端不应该依赖它不需要的接口,即一个类对另一个类的依赖应该建立在最小的接口上

  4. 先看一张图:

    image-20210411004513392

  5. 类 A 通过接口 Interface1 依赖类 B,类 C 通过接口 Interface1 依赖类 D,如果接口 Interface1 对于类 A 和类 C来说不是最小接口,那么类 B 和类 D 必须去实现他们不需要的方法。

  6. 按隔离原则应当这样处理:

    将接口 Interface1 拆分为独立的几个接口**(这里我们拆分成 **3 个接口**)**,类 A 和类 C 分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则

2.4.2、应用实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class Segregation1 {
public static void main(String[] args) {
// TODO Auto-generated method stub
}
//接口
interface Interface1 {
void operation1();
void operation2();
void operation3();
void operation4();
void operation5();
}

class B implements Interface1 {
public void operation1() {
System.out.println("B 实现了 operation1");
}
public void operation2() {
System.out.println("B 实现了 operation2");
}
public void operation3() {
System.out.println("B 实现了 operation3");
}
public void operation4() {
System.out.println("B 实现了 operation4");
}
public void operation5() {
System.out.println("B 实现了 operation5");
}
}

class D implements Interface1 {
public void operation1() {
System.out.println("D 实现了 operation1");
}
public void operation2() {
System.out.println("D 实现了 operation2");
}
public void operation3() {
System.out.println("D 实现了 operation3");
}
public void operation4() {
System.out.println("D 实现了 operation4");
}
public void operation5() {
System.out.println("D 实现了 operation5");
}
}

class A { //A 类通过接口 Interface1 依赖(使用) B 类,但是只会用到 1,2,3 方法
public void depend1(Interface1 i) {
i.operation1();
}
public void depend2(Interface1 i) {
i.operation2();
}
public void depend3(Interface1 i) {
i.operation3();
}
}

class C { //C 类通过接口 Interface1 依赖(使用) D 类,但是只会用到 1,4,5 方法
public void depend1(Interface1 i) {
i.operation1();
}
public void depend4(Interface1 i) {
i.operation4();
}
public void depend5(Interface1 i) {
i.operation5();
}
}

}

2.4.3、应传统方法的问题和使用接口隔离原则改进

  1. 类 A 通过接口 Interface1 依赖类 B,类 C 通过接口 Interface1 依赖类 D,如果接口 Interface1 对于类 A 和类 C来说不是最小接口,那么类 B 和类 D 必须去实现他们不需要的方法

  2. 将接口 Interface1 拆分为独立的几个接口,类 A 和类 C 分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则

  3. 接口 Interface1 中出现的方法,根据实际情况拆分为三个接口:

    image-20210411010354543

  4. 代码实现

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    public class Segregation1 {
    public static void main(String[] args) {
    // TODO Auto-generated method stub
    // 使用一把
    A a = new A();
    a.depend1(new B()); // A 类通过接口去依赖 B 类
    a.depend2(new B());
    a.depend3(new B());

    C c = new C();

    c.depend1(new D()); // C 类通过接口去依赖(使用)D 类
    c.depend4(new D());
    c.depend5(new D());
    }
    }

    // 接 口 1
    interface Interface1 {
    void operation1();
    }

    // 接 口 2
    interface Interface2 {
    void operation2();
    void operation3();
    }
    // 接 口 3
    interface Interface3 {
    void operation4();
    void operation5();
    }

    class B implements Interface1, Interface2 {
    public void operation1() {
    System.out.println("B 实现了 operation1");
    }
    public void operation2() {
    System.out.println("B 实现了 operation2");
    }
    public void operation3() {
    System.out.println("B 实现了 operation3");
    }
    }

    class D implements Interface1, Interface3 {
    public void operation1() {
    System.out.println("D 实现了 operation1");
    }
    public void operation4() {
    System.out.println("D 实现了 operation4");
    }
    public void operation5() {
    System.out.println("D 实现了 operation5");
    }
    }

    class A { // A 类通过接口 Interface1,Interface2 依赖(使用) B 类,但是只会用到 1,2,3 方法
    public void depend1(Interface1 i) {
    i.operation1();
    }
    public void depend2(Interface2 i) {
    i.operation2();
    }
    public void depend3(Interface2 i) {
    i.operation3();
    }
    }

    class C { // C 类通过接口 Interface1,Interface3 依赖(使用) D 类,但是只会用到 1,4,5 方法
    public void depend1(Interface1 i) {
    i.operation1();
    }
    public void depend4(Interface3 i) {
    i.operation4();
    }
    public void depend5(Interface3 i) {
    i.operation5();
    }
    }

2.4.4、接口隔离原则注意事项和细节

  1. 接口隔离原则的思想在于建立单一接口,尽可能地去细化接口,接口中的方法尽可能少
  2. 但是凡事都要有个度,如果接口设计过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。

2.5 、依赖倒转原则(Dependence Inversion Principle)

2.5.1、基本介绍

依赖倒转原则(Dependence Inversion Principle)是指:

  1. 高层模块不应该依赖低层模块,二者都应该依赖其抽象
  2. 抽象不应该依赖细节,细节应该依赖抽象
  3. 依赖倒转(倒置)的中心思想是面向接口编程面向抽象编程,解耦调用和被调用者
  4. 依赖倒转原则是基于这样的设计理念:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建的架构比以细节为基础的架构要稳定的多。在 java 中,抽象指的是接口或抽象类,细节就是具体的实现类
  5. 当两个模块之间存在紧密的耦合关系时,最好的方法就是分离接口和实现:在依赖之间定义一个抽象的接口使得高层模块调用接口,而底层模块实现接口的定义,以此来有效控制耦合关系,达到依赖于抽象的设计目标。
  6. 使用接口或抽象类的目的是制定好规范,而不涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成

2.5.2、应用实例

请编程完成 Person 接收消息 的功能。

实现方案 1 + 分析说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DependecyInversion {
public static void main(String[] args) {
Person person = new Person();
person.receive(new Email());
}
}

class Email {
public String getInfo() {
return "电子邮件信息: hello,world";
}
}

//完成Person接收消息的功能
//方式1分析
//1. 简单,比较容易想到
//2. 但如果我们获取的对象是 微信,短信等等,则新增类,同时Perons也要增加相应的接收方法
//3. 解决思路:引入一个抽象的接口IReceiver, 表示接收者, 这样Person类与接口IReceiver发生依赖
// 因为Email, WeiXin 等等属于接收的范围,他们各自实现IReceiver 接口就ok, 这样我们就符号依赖倒转原则
class Person {
public void receive(Email email) {
System.out.println(email.getInfo());
}
}

实现方案 2(依赖倒转) + 分析说明(同时也满足了开闭原则ocp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class DependecyInversion {
public static void main(String[] args) {
//客户端无需改变
Person person = new Person();
person.receive(new Email());

person.receive(new WeiXin());
}
}

//定义接口
interface IReceiver {
public String getInfo();
}

class Email implements IReceiver {
public String getInfo() {
return "电子邮件信息: hello,world";
}
}

//增加微信
class WeiXin implements IReceiver {
public String getInfo() {
return "微信信息: hello,ok";
}
}

//方式2
class Person {
//这里我们是对接口的依赖
public void receive(IReceiver receiver ) {
System.out.println(receiver.getInfo());
}
}

2.5.3、 依赖关系传递的三种方式和应用案例

2.5.3.1、接口传递

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class DependencyPass {

public static void main(String[] args) {
ChangHong changHong = new ChangHong();
// 方式1: 通过接口传递实现依赖
OpenAndClose openAndClose = new OpenAndClose();
openAndClose.open(changHong);
}

}

// 方式1: 通过接口传递实现依赖
// 开关的接口
interface IOpenAndClose {
//抽象方法,接收接口
public void open(ITV tv);
}
//ITV接口
interface ITV {
public void play();
}

class ChangHong implements ITV {
@Override
public void play() {
System.out.println("长虹电视机,打开");
}
}
// 实现IOpenAndClose接口
class OpenAndClose implements IOpenAndClose{
public void open(ITV tv){
tv.play();
}
}
// 实现ITV接口
class ChangHong implements ITV {
@Override
public void play() {
// TODO Auto-generated method stub
System.out.println("长虹电视机,打开");
}
}
2.5.3.2、构造方法传递

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class DependencyPass {
public static void main(String[] args) {
ChangHong changHong = new ChangHong();
// 方式2: 通过构造方法依赖传递
OpenAndClose openAndClose = new OpenAndClose(changHong);
openAndClose.open();
}
}

// 方式2: 通过构造方法依赖传递
interface IOpenAndClose {
//抽象方法
public void open();
}
//ITV接口
interface ITV {
public void play();
}
// 实现IOpenAndClose接口
class OpenAndClose implements IOpenAndClose{
//成员
public ITV tv;
//构造器
public OpenAndClose(ITV tv){
this.tv = tv;
}
public void open(){
this.tv.play();
}
}
// 实现ITV接口
class ChangHong implements ITV {
@Override
public void play() {
// TODO Auto-generated method stub
System.out.println("长虹电视机,打开");
}
}
2.5.3.3、setter 方式传递

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class DependencyPass {
public static void main(String[] args) {
ChangHong changHong = new ChangHong();
//通过setter方法进行依赖传递
OpenAndClose openAndClose = new OpenAndClose();
openAndClose.setTv(changHong);
openAndClose.open();
}
}

// 方式3 , 通过setter方法传递
interface IOpenAndClose {
// 抽象方法
public void open();
// setter方法
public void setTv(ITV tv);
}
// ITV接口
interface ITV {
public void play();
}
// 实现IOpenAndClose接口
class OpenAndClose implements IOpenAndClose {
private ITV tv;
public void setTv(ITV tv) {
this.tv = tv;
}
public void open() {
this.tv.play();
}
}
// 实现ITV接口
class ChangHong implements ITV {
@Override
public void play() {
// TODO Auto-generated method stub
System.out.println("长虹电视机,打开");
}
}

2.5.4、依赖倒转原则的注意事项和细节

  1. 低层模块尽量都要有抽象类或接口,或者两者都有,程序稳定性更好.
  2. 变量的声明类型尽量是抽象类或接口, 这样我们的变量引用和实际对象间,就存在一个缓冲层,利于程序扩展和优化
  3. 继承时遵循里氏替换原则.

2.6、里氏替换原则(Liskov Substitution Principle)

2.6.1、OO 中的继承性的思考和说明

  1. 继承包含这样一层含义:父类中凡是已经实现好的方法,实际上是在设定规范和契约,虽然它不强制要求所有的子类必须遵循这些契约,但是如果子类对这些已经实现的方法任意修改,就会对整个继承体系造成破坏
  2. 继承在给程序设计带来便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低增加对象间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能产生故障
  3. 里氏替换原则的重点在不影响原功能,而不是不覆盖原方法。
  4. 问题提出:在编程中,如何正确的使用继承? => 里氏替换原则

2.6.2、 基本介绍

  1. 里氏替换原则(Liskov Substitution Principle)在 1988 年,由麻省理工学院的以为姓里的女士提出的。
  2. 里氏替换原则的含义是:子类可以在任何地方替换它的父类。
  3. 也就是说在程序中将基类替换为子类,程序的行为不会发生任何变化。
  4. Liskov替换原则是关于继承机制的设计原则违反了Liskov替换原则就必然导致违反开放封闭原则
  5. 如果对每个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。换句话说,所有引用基类的地方必须能透明地使用其子类的对象
  6. 在使用继承时,遵循里氏替换原则,在**子类中尽量不要重写父类的方法**。
  7. 里氏替换原则告诉我们,继承实际上让两个类耦合性增强了,在适当的情况下,可以通过**聚合,组合,依赖**来解决问题。

里氏原则的优点:

  1. 能够保证系统具有良好的拓展性
  2. 同时实现基于多态的抽象机制
  3. 能够减少代码冗余
  4. 避免运行期的类型判别

2.6.3、 一个程序引出的问题和思考

image-20210411022902982

程序员原本是想调用b中继承的a的func1的方法求出11-3,但b无意重写了a的func1方法,使相减变成了相加。

2.6.4、解决方法

  1. 我们发现原来运行正常的相减功能发生了错误。原因就是类 B 无意中重写了父类的方法,造成原有功能出现错误。在实际编程中,我们常常会通过重写父类的方法完成新的功能,这样写起来虽然简单,但整个继承体系的复用性会比较差。特别是运行多态比较频繁的时候

  2. 通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖,聚合,组合等关系代替.

  3. 即:子类可以扩展父类的功能,但不能改变父类原有的功能。

  4. 改进方案:

    image-20210411023703967

    代码实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    public class Liskov {
    public static void main(String[] args) {
    A a = new A();
    System.out.println("11-3=" + a.func1(11, 3));
    System.out.println("1-8=" + a.func1(1, 8));
    System.out.println("-----------------------");
    B b = new B();
    //因为B类不再继承A类,因此调用者,不会再认为func1是求减法
    //调用完成的功能就会很明确
    System.out.println("11+3=" + b.func1(11, 3));//这里本意是求出11+3
    System.out.println("1+8=" + b.func1(1, 8));// 1+8
    System.out.println("11+3+9=" + b.func2(11, 3));

    //使用组合仍然可以使用到A类相关方法
    System.out.println("11-3=" + b.func3(11, 3));// 这里本意是求出11-3
    }
    }
    //创建一个更加基础的基类
    class Base {
    //把更加基础的方法和成员写到Base类
    public int func1(int num1, int num2) {}
    }
    // A类继承了Base
    class A extends Base {
    // 重写func1返回两个数的差
    public int func1(int num1, int num2) {
    return num1 - num2;
    }
    }
    // B类继承了Base
    // 增加了一个新功能:完成两个数相加,然后和9求和
    class B extends Base {
    //如果B需要使用A类的方法,使用组合关系
    private A a = new A();

    //这里,重写了Base类的方法,
    public int func1(int a, int b) {
    return a + b;
    }

    public int func2(int a, int b) {
    return func1(a, b) + 9;
    }

    //我们仍然想使用A的方法
    public int func3(int a, int b) {
    return this.a.func1(a, b);
    }
    }

2.7、开闭原则(Open Closed Principle)

2.7.1、基本介绍

  1. 开闭原则(Open Closed Principle)是编程中最基础、最重要的设计原则
  2. 一个软件实体如类,模块和函数应该对扩展开放(对提供方)**,对修改关闭(对使用方)。用抽象构建框架,用实现扩展细节**。
  3. 采用逆向思维方式来想。如果每次需求变动都去修改原有的代码,那原有的代码就存在被修改错误的风险,当然这其中存在有意和无意的修改,都会导致原有正常运行的功能失效的风险,这样很有可能会展开可怕的蝴蝶效应,使维护工作剧增。
  4. 所以当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。
  5. 编程中遵循其它原则,以及使用设计模式的目的就是遵循开闭原则

2.7.2、看下面一段代码

实现画图形的功能

类图:

image-20210411025709019

代码:

image-20210411025938974

但我们增加一个功能:画三角形

方式1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Ocp {
public static void main(String[] args) {
GraphicEditor graphicEditor = new GraphicEditor();
graphicEditor.drawShape(new Rectangle());
graphicEditor.drawShape(new Circle());
//使用看看存在的问题
graphicEditor.drawShape(new Triangle());
}
}

//这是一个用于绘图的类 [使用方]
class GraphicEditor {
//接收Shape对象,然后根据type,来绘制不同的图形
public void drawShape(Shape s) {
if (s.m_type == 1)
drawRectangle(s);
else if (s.m_type == 2)
drawCircle(s);
// 修改1
else if (s.m_type == 3)
drawTriangle(s);
}
//绘制矩形
public void drawRectangle(Shape r) {
System.out.println(" 绘制矩形 ");
}
//绘制圆形
public void drawCircle(Shape r) {
System.out.println(" 绘制圆形 ");
}
//绘制三角形
//修改2
public void drawTriangle(Shape r) {
System.out.println(" 绘制三角形 ");
}
}
//Shape类,基类
class Shape {
int m_type;
}
class Rectangle extends Shape {
Rectangle() {
super.m_type = 1;
}
}
class Circle extends Shape {
Circle() {
super.m_type = 2;
}
}
//新增功能:画三角形
class Triangle extends Shape {
Triangle() {
super.m_type = 3;
}
}

2.7.3、方式 1 的优缺点

  1. 优点是比较好理解,简单易操作。

  2. 缺点是违反了设计模式的 ocp 原则,即**对扩展开放(提供方),对修改关闭(使用方)**。即当我们给类增加新功能的时候,尽量不修改代码,或者尽可能少修改代码.

  3. 比如我们这时要新增加一个图形种类三角形,我们需要做如下修改,修改的地方较多(使用方要修改两次)

  4. 代码演示(方式2)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    public class Ocp {

    public static void main(String[] args) {
    GraphicEditor graphicEditor = new GraphicEditor();
    graphicEditor.drawShape(new Rectangle());
    graphicEditor.drawShape(new Circle());
    //使用方直接使用
    graphicEditor.drawShape(new Triangle());
    }

    }
    //这是一个用于绘图的类 [使用方]
    class GraphicEditor {
    //接收Shape对象,调用draw方法
    public void drawShape(Shape s) {
    s.draw();
    }
    }
    //Shape类,基类(使用抽象类)
    abstract class Shape {
    //抽象方法
    public abstract void draw();
    }
    // 继承抽象类Shape
    class Rectangle extends Shape {
    @Override
    public void draw() {
    // TODO Auto-generated method stub
    System.out.println(" 绘制矩形 ");
    }
    }
    // 继承抽象类Shape
    class Circle extends Shape {
    @Override
    public void draw() {
    // TODO Auto-generated method stub
    System.out.println(" 绘制圆形 ");
    }
    }
    //新增功能:画三角形
    // 继承抽象类Shape
    class Triangle extends Shape {
    @Override
    public void draw() {
    // TODO Auto-generated method stub
    System.out.println(" 绘制三角形 ");
    }
    }

2.7.4、改进的思路分析

把创建 Shape 类做成抽象类,并提供一个抽象的 draw 方法,让子类去实现即可,这样我们有新的图形种类时,只需要让新的图形类继承 Shape,并实现 draw 方法即可,使用方的代码就不需要修改-> 满足了开闭原则

2.7.5、开闭原则注意事项和细节

  1. OCP 可以具有良好的可扩展性,可维护性。
  2. 不可能让一个系统的所有模块都满足 OCP 原则,我们能做到的是尽可能地不要修改已经写好的代码,已有的功能,而是去扩展它。

2.8、迪米特法则(Demeter Principle)

2.8.1、基本介绍

  1. 迪米特原则要求尽量的封装,尽量的独立,尽量的使用低级别的访问修饰符。就是说一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。
  2. 一个对象应该对其他对象保持最少的了解
  3. 类与类关系越密切,耦合度越大
  4. 迪米特法则(Demeter Principle)又叫最少知道原则,即一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类不管多么复杂,都尽量将逻辑封装在类的内部。对外除了提供的 public 方法,不对外泄露任何信息
  5. 迪米特法则还有个更简单的定义:只与直接的朋友通信
  6. 直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖关联组合聚合等。其中,我们称出现成员变量方法参数方法返回值中的类为直接的朋友,而出现在局部变量中的类不是直接的朋友。也就是说,陌生的类最好不要以局部变量的形式出现在类的内部。最好将其封装到直接朋友里面。
  7. 迪米特原则要求类之间的直接联系尽量的少,两个类的访问,通过第三个中介类来实现。

2.8.2、应用实例

有一个学校,下属有各个学院和总部,现要求打印出学校总部员工 ID 和学院员工的 id。编程实现上面的功能, 看代码演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
//客户端
public class Demeter1 {
public static void main(String[] args) {
//创建了一个 SchoolManager 对象
SchoolManager schoolManager = new SchoolManager();
//输出学院的员工id 和 学校总部的员工信息
schoolManager.printAllEmployee(new CollegeManager());
}
}
//学校总部员工类
class Employee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}
//学院的员工类
class CollegeEmployee {
private String id;
public void setId(String id) {
this.id = id;
}
public String getId() {
return id;
}
}

//管理学院员工的管理类
class CollegeManager {
////添加学院的员工
public List<CollegeEmployee> getAllEmployee() {
List<CollegeEmployee> list = new ArrayList<CollegeEmployee>();
for (int i = 0; i < 10; i++) { //这里我们增加了10个员工到 list
CollegeEmployee emp = new CollegeEmployee();
emp.setId("学院员工id= " + i);
list.add(emp);
}
return list;
}
}
//学校管理类
//分析 SchoolManager 类的直接朋友类有哪些 Employee、CollegeManager
//CollegeEmployee 不是 直接朋友 而是一个陌生类,这样违背了 迪米特法则
class SchoolManager {
//返回学校总部的员工
public List<Employee> getAllEmployee() {
List<Employee> list = new ArrayList<Employee>();
//这里我们增加了5个员工到 list
for (int i = 0; i < 5; i++) {
Employee emp = new Employee();
emp.setId("学校总部员工id= " + i);
list.add(emp);
}
return list;
}
//该方法完成输出学校总部和学院员工信息(id)
void printAllEmployee(CollegeManager sub) {
//分析问题
//1. 这里的 CollegeEmployee 不是 SchoolManager的直接朋友
//2. CollegeEmployee 是以局部变量方式出现在 SchoolManager
//3. 违反了 迪米特法则

//获取到学院员工
List<CollegeEmployee> list1 = sub.getAllEmployee();
System.out.println("------------学院员工------------");
for (CollegeEmployee e : list1) {
System.out.println(e.getId());
}
//获取到学校总部员工
List<Employee> list2 = this.getAllEmployee();
System.out.println("------------学校总部员工------------");
for (Employee e : list2) {
System.out.println(e.getId());
}
}
}

2.8.3、应用实例改进

  1. 前面设计的问题在于 SchoolManager 中,CollegeEmployee 类并不是 SchoolManager 类的直接朋友 (分析)

  2. 按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合

  3. 对代码按照迪米特法则 进行改进:

  4. 代码演示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    //客户端
    public class Demeter1 {
    public static void main(String[] args) {
    //创建了一个 SchoolManager 对象
    SchoolManager schoolManager = new SchoolManager();
    //输出学院的员工id 和 学校总部的员工信息
    schoolManager.printAllEmployee(new CollegeManager());
    }
    }
    //学校总部员工类
    class Employee {
    private String id;
    public void setId(String id) {
    this.id = id;
    }
    public String getId() {
    return id;
    }
    }
    //学院的员工类
    class CollegeEmployee {
    private String id;
    public void setId(String id) {
    this.id = id;
    }
    public String getId() {
    return id;
    }
    }
    //管理学院员工的管理类
    class CollegeManager {
    //添加学院的员工
    public List<CollegeEmployee> getAllEmployee() {
    List<CollegeEmployee> list = new ArrayList<CollegeEmployee>();
    //这里我们增加了10个员工到 list
    for (int i = 0; i < 10; i++) {
    CollegeEmployee emp = new CollegeEmployee();
    emp.setId("学院员工id= " + i);
    list.add(emp);
    }
    return list;
    }
    //输出学院员工的信息
    public void printEmployee() {
    //获取到学院员工
    List<CollegeEmployee> list1 = getAllEmployee();
    System.out.println("------------学院员工------------");
    for (CollegeEmployee e : list1) {
    System.out.println(e.getId());
    }
    }
    }
    //学校管理类
    class SchoolManager {
    //返回学校总部的员工
    public List<Employee> getAllEmployee() {
    List<Employee> list = new ArrayList<Employee>();
    //这里我们增加了5个员工到 list
    for (int i = 0; i < 5; i++) {
    Employee emp = new Employee();
    emp.setId("学校总部员工id= " + i);
    list.add(emp);
    }
    return list;
    }
    //该方法完成输出学校总部和学院员工信息(id)
    void printAllEmployee(CollegeManager sub) {
    //分析问题
    //1. 将输出学院的员工方法,封装到CollegeManager
    sub.printEmployee();
    //获取到学校总部员工
    List<Employee> list2 = this.getAllEmployee();
    System.out.println("------------学校总部员工------------");
    for (Employee e : list2) {
    System.out.println(e.getId());
    }
    }
    }

2.8.4、迪米特法则注意事项和细节

  1. 迪米特法则的核心是降低类之间的耦合

  2. 但是注意:由于每个类都减少了不必要的依赖,因此迪米特法则只是要求降低类间(对象间)耦合关系, 并不是要求完全没有依赖关系

2.9、合成复用原则(Composite Reuse Principle)

2.9.1、基本介绍

原则是尽量使用合成/聚合的方式,而不是使用继承

聚合组合是一种 “黑箱” 复用,因为细节对象的内容对客户端来说是不可见的。

因为继承的耦合性更大,组合聚合只是引用其他的类的方法,而不会受引用的类的继承而改变血统。说白了就是我只用你的方法,但我们并不是同类。

在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生了改变,则子类的实现也不得不改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。于是就提出了组合/聚合复用原则,也就是在实际开发设计中,尽量使用组合/聚合,不要使用类继承。

image-20210411150334377

2.10、设计原则核心思想

  1. 找出应用中可能需要变化之处,把它们独立出来,不要和那些不需要变化的代码混在一起。
  2. 针对接口编程,而不是针对实现编程。
  3. 为了交互对象之间的松耦合设计而努力

2.11、设计七大原则总结

这 7 种设计原则是软件设计模式必须尽量遵循的原则,是设计模式的基础。在实际开发过程中,并不是一定要求所有代码都遵循设计原则,而是要综合考虑人力、时间、成本、质量,不刻意追求完美,要在适当的场景遵循设计原则。这体现的是一种平衡取舍,可以帮助我们设计出更加优雅的代码结构。

各种原则要求的侧重点不同,下面我们分别用一句话归纳总结软件设计模式的七大原则,如下表所示。

设计原则 一句话归纳 目的
开闭原则 对扩展开放,对修改关闭 降低维护带来的新风险
依赖倒置原则 高层不应该依赖低层,要面向接口编程 更利于代码结构的升级扩展
单一职责原则 一个类只干一件事,实现类要单一 便于理解,提高代码的可读性
接口隔离原则 一个接口只干一件事,接口要精简单一 功能解耦,高聚合、低耦合
迪米特法则 不该知道的不要知道,一个类应该保持对其它对象最少的了解,降低耦合度 只和朋友交流,不和陌生人说话,减少代码臃肿
里氏替换原则 不要破坏继承体系,子类重写方法功能发生改变,不应该影响父类方法的含义 防止继承泛滥
合成复用原则 尽量使用组合或者聚合关系实现代码复用,少使用继承 降低代码耦合

实际上,这些原则的目的只有一个:降低对象之间的耦合,增加程序的可复用性、可扩展性和可维护性

记忆口诀:访问加限制,函数要节俭,依赖不允许,动态加接口,父类要抽象,扩展不更改。

在程序设计时,我们应该将程序功能最小化,每个类只干一件事。若有类似功能基础之上添加新功能,则要合理使用继承。对于多方法的调用,要会运用接口,同时合理设置接口功能与数量。最后类与类之间做到低耦合高内聚。

3、UML 类图

3.1、UML 基本介绍

  1. UML——Unified modeling language UML (统一建模语言),是一种用于软件系统分析和设计的语言工具,它用于帮助软件开发人员进行思考和记录思路的结果

  2. UML 本身是一套符号的规定,就像数学符号和化学符号一样,这些符号用于描述软件模型中的各个元素和他们之间的关系,比如类、接口、实现、泛化、依赖、组合、聚合等,如右图:

    image-20210411172838273

    image-20210411173002251

  3. 使用 UML 来建模,常用的工具有 Rational Rose , 也可以使用一些插件来建模

3.2、UML 图

画 UML 图与写文章差不多,都是把自己的思想描述给别人看,关键在于思路和条理,UML 图分类:

  1. 用例图(use case)
  2. 静态结构图:类图、对象图、包图、组件图、部署图
  3. 动态行为图:交互图(时序图与协作图)、状态图、活动图

说明:

  1. 类图是描述类与类之间的关系的,是 UML 图中最核心的
  2. 在讲解设计模式时,我们必然会使用类图,为了让学员们能够把设计模式学到位,需要先给大家讲解类图

3.3、UML 类图

  1. 用于描述系统中的类**(对象)本身的组成和类(对象)**之间的各种静态关系。

  2. 类之间的关系:依赖、泛化(继承)、实现、关联、聚合与组合。

  3. 类图简单举例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class Person{ //代码形式->类图
    private Integer id; private String name;
    public void setName(String name){
    this.name=name;
    }
    public String getName(){
    return name;
    }
    }
  4. 类图

    image-20210411192255196

3.4、类图—依赖关系(Dependence)

只要是在类中用到了对方,那么他们之间就存在依赖关系。如果没有对方,连编绎都通过不了。

对应类图:

image-20210411192418668

依赖关系小结:

  1. 类中用到了对方
  2. 如果是类的成员属性
  3. 如果是方法的返回类型
  4. 是方法接收的参数类型
  5. 方法中使用到

3.5、类图—泛化关系(generalization)

泛化关系实际上就是继承关系,他是依赖关系的特例

相关类图:

image-20210411192748570

泛化关系小结:

  1. 泛化关系实际上就是继承关系
  2. 如果 A 类继承了 B 类,我们就说 A 和 B 存在泛化关系

3.6、类图—实现关系(Implementation)

实现关系实际上就是 A 类实现 B 接口,他是依赖关系的特例

相关类图:

image-20210411193029031

3.7、类图—关联关系(Association)

image-20210411193416238

3.8、类图—聚合关系(Aggregation)

3.8.1、基本介绍

聚合关系(Aggregation)表示的是整体和部分的关系整体与部分可以分开。聚合关系是关联关系的特例,所以他具有关联的导航性与多重性

如:一台电脑由键盘(keyboard)、显示器(monitor),鼠标等组成;组成电脑的各个配件是可以从电脑上分离出来的,使用带空心菱形的实线来表示:

image-20210411193519679

3.9、类图—组合关系(Composition)

3.9.1、基本介绍

组合关系:也是整体与部分的关系,但是整体与部分不可以分开

再看一个案例:在程序中我们定义实体:Person 与 IDCard、Head, 那么 Head 和 Person 就是 组合IDCard 和Person 就是聚合。

但是如果在程序中 Person 实体中定义了对 IDCard 进行级联删除,即删除 Person 时连同 IDCard 一起删除,那么 IDCard 和 Person 就是组合了.

代码:

1
2
3
4
5
6
7
public class Person{ 
private IDCard card;
// 在创建Person对象的同时创建了Head对象
private Head head = new Head();
}
public class IDCard{}
public class Head{}

对应类图:

image-20210411193901631

4、设计模式概述

4.1、设计模式介绍

  1. 设计模式是程序员在面对同类软件工程设计问题所总结出来的有用的经验,模式不是代码,而是某类问题的通用解决方案,设计模式(Design pattern)代表了最佳的实践。这些解决方案是众多软件开发人员经过相当长的一段时间的试验和错误总结出来的。
  2. 设计模式的本质提高软件的维护性,通用性和扩展性,并降低软件的复杂度
  3. <<设计模式>> 是经典的书,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides Design(俗称 “四人组 GOF”)
  4. 设计模式并不局限于某种语言,java,php,c++ 都有设计模式.

4.2、设计模式类型

设计模式分为三种类型,共 23

  1. 创建型模式:单例模式、抽象工厂模式、原型模式、建造者模式、工厂模式
  2. 结构型模式:适配器模式、桥接模式、装饰模式、组合模式、外观模式、享元模式、代理模式
  3. 行为型模式:模版方法模式、命令模式、访问者模式、迭代器模式、观察者模式、中介者模式、备忘录模式、解释器模式(Interpreter 模式)、状态模式、策略模式、职责链模式(责任链模式)。

注意:不同的书籍上对分类和名称略有差别

对于创建型模式的概述请看第27点

对于结构型模式的概述请看第28点

对于行为型模式的概述请看第29点

5、单例设计模式Singleton(创建型设计模式)

image-20210415031845856

5.1、单例设计模式介绍

所谓类的单例设计模式,就是采取一定的方法保证在整个的软件系统中,对某个类只能存在一个对象实例, 并且该类**只提供一个取得其对象实例的方法(静态方法)**。

比如 Hibernate 的 SessionFactory,它充当数据存储源的代理,并负责创建 Session 对象。SessionFactory 并不是轻量级的,一般情况下,一个项目通常只需要一个 SessionFactory 就够,这是就会使用到单例模式

注意:

  1. 单例类只有一个实例对象;
  2. 该单例对象必须由单例类自行创建;
  3. 单例类对外提供一个访问该单例的全局访问点。

测试方法(除了枚举):

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {
public static void main(String[] args) {
//判断创建的两个实例是不是同一个
Singleton instance = Singleton.getInstance();
Singleton instance2 = Singleton.getInstance();
// true
System.out.println(instance == instance2);
System.out.println("instance.hashCode=" + instance.hashCode());
System.out.println("instance2.hashCode=" + instance2.hashCode());
}
}

5.2、单例设计模式八种方式

加黑属于推荐使用

  1. 饿汉式(两种)
    1. 饿汉式**(静态常量)**
    2. 饿汉式(静态代码块)
  2. 懒汉式(三种)
    1. 懒汉式(线程不安全)
    2. 懒汉式(线程安全,同步方法)
    3. 懒汉式(同步代码块)
  3. 双重检查
  4. 静态内部类
  5. 枚举

5.3、饿汉式(两种)

5.3.1、饿汉式(静态常量)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 类的内部创建对象
  3. 向外暴露一个静态的公共方法getInstance

代码实现:

1
2
3
4
5
6
7
8
9
10
11
// 饿汉式(静态常量)
final class Singleton {
//1. 构造器私有化, 外部不能new
private Singleton() {}
//2.本类内部创建对象实例
private final static Singleton instance = new Singleton();
//3. 提供一个公有的静态方法,返回实例对象
public static Singleton getInstance() {
return instance;
}
}

优缺点说明:

  • 优点:这种写法比较简单,就是在类装载的时候就完成实例化。避免了线程同步问题。
  • 缺点:在类装载的时候就完成实例化,没有达到 Lazy Loading 的效果。如果从始至终从未使用过这个实例,则会造成内存的浪费
  • 这种方式基于 classloder 机制避免了多线程的同步问题,不过,instance 在类装载时就实例化,在单例模式中大多数都是调用 getInstance 方法, 但是导致类装载的原因有很多种,因此不能确定有其他的方式(或者其他的静态方法)导致类装载,这时候初始化 instance 就没有达到 lazy loading 的效果
  • 结论:这种单例模式可用可能造成内存浪费,同时也不能实现懒加载(lazy loading)

5.3.2、饿汉式(静态代码块)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 静态代码块中,创建单例对象
  3. 向外暴露一个静态的公共方法getInstance

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 饿汉式(静态代码块)
final class Singleton {
//1. 构造器私有化, 外部不能new
private Singleton() {}
// 2.在静态代码块中,创建单例对象
private static Singleton instance;
static {
instance = new Singleton();
}
//3. 提供一个公有的静态方法,返回实例对象
public static Singleton getInstance() {
return instance;
}
}

优缺点说明:

  1. 这种方式和上面的方式其实类似,只不过将类实例化的过程放在了静态代码块中,也是在类装载的时候,就执行静态代码块中的代码初始化类的实例。优缺点和上面是一样的。
  2. 结论:这种单例模式可用,但是可能造成内存浪费

5.4、懒汉式(三种)

5.4.1、懒汉式(线程不安全)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法getInstance(),当使用到该方法时,才去创建 instance

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 懒汉式(线程不安全)
final class Singleton {
private static Singleton instance;
// 1.构造器私有化(防止外部使用new创建实例)
private Singleton() {}

//2.提供一个静态的公有方法,当使用到该方法时,才去创建 instance
//即懒汉式
public static Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}

优缺点说明:

  1. 起到了 Lazy Loading 的效果,但是只能在单线程下使用
  2. 如果在多线程下,一个线程进入了 if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。所以在多线程环境下不可使用这种方式。(线程不安全)
  3. 结论:在实际开发中,不要使用这种方式.

5.4.2、懒汉式(线程安全,同步方法)

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法getInstance(),加入同步处理的代码synchronized ,解决线程安全问题

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 懒汉式(线程安全,同步方法)
final class Singleton {
private static Singleton instance;
// 1.构造器私有化(防止外部使用new创建实例)
private Singleton() {}
// 2.提供一个静态的公有方法,加入同步处理的代码,解决线程安全问题
//即懒汉式
public static synchronized Singleton getInstance() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}

优缺点说明:

  1. 解决了线程安全问题
  2. 效率太低了,每个线程在想获得类的实例时候,执行getInstance()方法都要进行同步。而其实这个方法只执行一次实例化代码就够了后面的想获得该类实例,直接 return 就行了。方法进行同步效率太低。
  3. 结论:在实际开发中,不推荐使用这种方式

5.4.3、懒汉式(同步代码块)

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法getInstance(),加入同步产生实例化的的代码块,解决效率问题。

代码:

image-20210412194454470

优缺点说明:

  1. 这种方式,本意是想对第四种实现方式的改进,因为前面同步方法效率太低,改为同步产生实例化的的代码块。
  2. 但是这种同步并不能起到线程同步的作用。跟第3种实现方式遇到的情形一致,假如一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。(线程不安全
  3. 结论:在实际开发中,**不能使用**这种方式

5.5、双重检查

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 提供一个静态的公有方法,加入**双重检查代码(双if)**,解决线程安全问题, 同时解决懒加载问题

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 双重检查
final class Singleton {
private static volatile Singleton instance;
private Singleton() {}
//提供一个静态的公有方法,加入双重检查代码,解决线程安全问题, 同时解决懒加载问题
//同时保证了效率, 推荐使用
public static Singleton getInstance() {
if(instance == null) {
synchronized (Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

优缺点说明:

  1. Double-Check 概念是多线程开发中常使用到的,如代码中所示,我们进行了两次 if (singleton == null)检查,这样就可以保证线程安全了。
  2. 是对懒汉式(线程安全,同步方法)的优化
  3. 这样,实例化代码只用执行一次,后面再次访问时,判断 if (singleton == null),直接 return 实例化对象,也避免的反复进行方法同步
  4. 线程安全延迟加载效率较高
  5. 结论:在实际开发中,推荐使用这种单例设计模式

其他说明:

  • 双重检锁虽然是线程安全的,会出现内部成员变量空指针异常,如果要使用,需将类实例用volatile修饰
  • volatile 是改变立即更新到主存保证变化各线程可见,即:立即从主内存中获取值,更新工作内存的值。在多线程情况下,不仅防止指令重排,而且保证happes-before规则,前一个线程的操作对后一个线程可见。(happens-before规则相关)
  • 如果不用volatile关键字,有可能会出现异常。因为instance = new Singleton();并不是一个原子操作。new对象分为三步:
    1. 第一步:分配对象的内存空间
    2. 第二步:初始化对象
    3. 第三步:设置instance指向内存空间
  • 但是这个被返回的instance是有问题的——它还没有被初始化(第二步还未被执行)。
  • 这里必须要volatile,volatile就是保证一个线程更新了instance,其余线程立马可知,不然第二个if没有用。(可见性)
  • volitile保证了线程间的可见性,和一定程度上的顺序性(不能保证原子性),更好的方式是用一个boolean变量标识对象是否创建过(原子性)

双重检查创建单例实现步骤

  1. 第一个if(singleton==null){}:第一层检查,检查是否有引用指向对象,高并发情况下会有多个线程同时进
  2. synchronized (Singleton.class) {}:第一层锁,保证只有一个线程进入
  3. 第二个if(singleton==null){}:第二层检查
    • 双重检查,防止多个线程同时进入第一层检查(因单例模式只允许存在一个对象,故在创建对象之前无引用指向对象,所有线程均可进入第一层检查)
    • 当某一线程获得锁创建一个Singleton对象时,即已有引用指向对象,singleton不为空,从而保证只会创建一个对象
    • 假设没有第二层检查,那么第一个线程创建完对象释放锁后,后面进入对象也会创建对象,会产生多个对象。(5.4.3的情况)
  4. instance = new Singleton():volatile关键字作用为禁止指令重排,保证返回Singleton对象一定在创建对象后
    • 该语句为非原子性,实际上会执行以下内容:
      1. 在堆上开辟空间
      2. 属性初始化
      3. 引用指向对象
    • 假设以上三个内容为三条单独指令,因指令重排可能会导致执行顺序为1->3->2(正常为1->2->3),当单例模式中存在普通变量需要在构造方法中进行初始化操作时,单线程情况下,顺序重排没有影响;但在多线程情况下,假如线程1执行singleton=new Singleton()语句时先1再3,由于系统调度线程2的原因没来得及执行步骤2,但此时已有引用指向对象也就是singleton!=null,故线程2在第一次检查时不满足条件直接返回singleton,此时singleton为一个没有被步骤2正确初始化的singleton。
    • volatile关键字可保证singleton=new Singleton()语句执行顺序为123,因其为非原子性依旧可能存在系统调度问题(即执行步骤时被打断),但能确保的是只要singleton!=null,就表明一定执行了属性初始化操作;而若在步骤3之前被打断,此时singleton依旧为null,其他线程可进入第一层检查向下执行创建对象。

5.6、静态内部类

使用步骤:

  1. 构造器私有化 (防止外部使用new创建实例)
  2. 写一个静态内部类,该类中有一个静态属性 Singleton
  3. 提供一个静态的公有方法,直接返回SingletonInstance.INSTANCE

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 静态内部类完成, 推荐使用
final class Singleton {
private static volatile Singleton instance;
// 1.构造器私有化
private Singleton() {}
// 2.写一个静态内部类,该类中有一个静态属性 Singleton
private static class SingletonInstance {
private static final Singleton INSTANCE = new Singleton();
}
// 3.提供一个静态的公有方法,直接返回SingletonInstance.INSTANCE
public static Singleton getInstance() {
return SingletonInstance.INSTANCE;
}
}

优缺点说明:

  1. 这种方式采用了类装载的机制保证初始化实例时只有一个线程
  2. 静态内部类方式在 Singleton 类被装载时并不会立即实例化,而是在需要实例化时,调用 getInstance 方法,才会装载 SingletonInstance 类,从而完成 Singleton 的实例化
  3. 类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM 帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。
  4. 优点:避免了线程不安全,利用静态内部类特点实现延迟加载效率高
  5. 缺点:不能传参
  6. 结论:在实际开发中,推荐使用这种单例设计模式

其他说明:

  • 静态内部类:这里的关键是类在加载的时候是线程安全的,一个类只会被加载一次
  • JVM初始化时机:
    1. 首次,主动使用才会初始化。即只有第一次加载类的时候初始化。
    2. 之后调用getInstance()方法,直接返回对象,不会再次初始化了
  • 这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。

5.7、枚举

使用步骤:

  1. 直接使用枚举实现单例
  2. 在枚举里面有INSTANCE属性
  3. 外部直接通过Singleton.INSTANCE的方式创建实例

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SingletonTest {
public static void main(String[] args) {
// 通过Singleton.INSTANCE的方式创建实例
Singleton instance = Singleton.INSTANCE;
Singleton instance2 = Singleton.INSTANCE;
System.out.println(instance == instance2);

System.out.println(instance.hashCode());
System.out.println(instance2.hashCode());

instance.sayOK();
}
}
// 使用枚举,可以实现单例, 推荐
final enum Singleton {
// 属性
INSTANCE;
// 自定义构造函数
int value;
private SingletonEnum() {
value = 1;
System.out.println("INSTANCE now created!");
}
// 方法
public void sayOK() {
System.out.println("ok~");
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}

优缺点说明:

  1. 这借助 JDK1.5 中添加的枚举来实现单例模式。不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象
  2. JVM 会阻止反射获取枚举类的私有构造方法
  3. 枚举真正实现了单例,把反序列化和反射创建第二对象的路都堵死了
  4. 这种方式是 Effective Java 作者 Josh Bloch 提倡的方式
  5. 缺点:无法进行懒加载。如果Singleton必须拓展一个超类,而不是扩展Enum的时候,则不宜使用这个方法。
  6. 结论:在实际开发中,推荐使用这种单例设计模式

5.8、单例模式在 JDK 应用的源码分析

我们 JDK 中,java.lang.Runtime 就是经典的单例模式(饿汉式)

原码:

image-20210413021321623

5.9、单例模式总结

5.9.1、单例模式的优缺点

优点:

  • 单例模式可以保证内存里只有一个实例,减少了内存的开销。
  • 可以避免对资源的多重占用。
  • 单例模式设置全局访问点,可以优化和共享资源的访问。

缺点:

  • 单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
  • 在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
  • 单例模式的功能代码通常写在一个类中,如果功能设计不合理,则很容易违背单一职责原则。

5.9.2、单例模式的应用场景

对于 Java 来说,单例模式可以保证在一个 JVM 中只存在单一实例。单例模式的应用场景主要有以下几个方面。

  • 需要频繁创建的一些类,使用单例可以降低系统的内存压力,减少 GC。
  • 某类只要求生成一个对象的时候,如一个班中的班长、每个人的身份证号等。
  • 某些类创建实例时占用资源较多,或实例化耗时较长,且经常使用。
  • 某类需要频繁实例化,而创建的对象又频繁被销毁的时候,如多线程的线程池网络连接池等。
  • 频繁访问数据库或文件的对象
  • 对于一些控制硬件级别的操作,或者从系统上来讲应当是单一控制逻辑的操作,如果有多个实例,则系统会完全乱套。
  • 对象需要被共享的场合。由于单例模式只允许创建一个对象,共享该对象可以节省内存,并加快对象访问速度。如 Web 中的配置对象、数据库的连接池等。

5.9.3、单例模式的结构

单例模式的主要角色如下。

  • 单例类:包含一个实例且能自行创建这个实例的类。
  • 访问类:使用单例的类。

结构:

image-20210412211111576

5.9.4、相关的设计模式

在以下模式中, 多数情况下只会生成一个实例。

  • AbstractFactory模式
  • Builder模式
  • Facade模式
  • Prototype模式

5.9.5、单例模式注意事项和细节说明

  • 单例模式保证了 系统内存中该类只存在一个对象,节省了系统资源,对于一些需要频繁创建销毁的对象,使用单例模式可以提高系统性能
  • 当想实例化一个单例类的时候,必须要记住使用相应的获取对象的方法,而不是使用 new
  • 枚举是最安全的单例,是不可破坏的,其余所有的单例都是可以用反射破坏的
  • 那我们什么时候应该用Singleton呢?实际上,很多程序,尤其是Web程序,大部分服务类都应该被视作Singleton,如果全部按Singleton的写法写,会非常麻烦,所以,通常是通过约定让框架(例如Spring)来实例化这些类,保证只有一个实例,调用方自觉通过框架获取实例而不是new操作符。因此,除非确有必要,否则Singleton模式一般以“约定”为主,不会刻意实现它。
  • 经验之谈:一般情况下,不建议使用第 1 种和第 2 种懒汉方式,建议使用第 3 种饿汉方式。只有在要明确实现 lazy loading 效果时,才会使用第 5 种静态内部方式。如果涉及到反序列化创建对象时,可以尝试使用第 6 种枚举方式。如果有其他特殊的需求,可以考虑使用第 4 种双检锁方式。

5.9.6、反射与反序列化破坏单例模式的方法及解决办法

除枚举方式外, 其他方法都会通过反射或反序列化的方式破坏单例

5.9.6.1、反射破坏单例模式

反射如何破坏单例模式

通过反射获得单例类的构造函数,由于该构造函数是private的,通过setAccessible(true)指示反射的对象在使用时应该取消 Java 语言访问检查,使得私有的构造函数能够被访问,这样使得单例模式失效。

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
public static void main(String[] args) throws Exception{
Singleton s1 = Singleton.getInstance();

Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton s2 = constructor.newInstance();

System.out.println(s1.hashCode());
System.out.println(s2.hashCode());
}
}

如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:

1
2
3
4
5
private SingletonObject1(){
if (instance !=null){
throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
}
}
5.9.6.2、反序列化破坏单例模式

序列化和反序列化的对单例破坏的防止及其原理

如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例。

我们使用正常的方式来获取一个对象。通过序列化将对象写入文件中,然后我们通过反序列化的到一个对象,我们再对比这个对象,输出的内存地址和布尔结果都表示这不是同一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HungrySingleton implements Serializable{
private final static HungrySingleton hungrySingleton;
static{
hungrySingleton = new HungrySingleton();
}
private HungrySingleton(){
if(hungrySingleton != null){
throw new RuntimeException("单例构造器禁止反射调用");
}
}
public static HungrySingleton getInstance(){
return hungrySingleton;
}
}

这里我们使用之前的饿汉式的单例作为例子。在之前饿汉式的代码上做点小改动。就是让我们的单例类实现 Serializable接口。然后我们在测试类中测试一下怎么破坏。

1
2
3
4
5
6
7
8
9
10
11
12
public class SingletonTest {
public static void main(String[] args) throws IOException, ClassNotFoundException {
HungrySingleton instance = HungrySingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
oos.writeObject(instance);
File file = new File("singleton_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
HungrySingleton newInstance = (HungrySingleton) ois.readObject();
System.out.println(instance == newInstance;
}

}

解决方法:

所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。

1
2
3
public Object readResolve() throws ObjectStreamException{
return instance;
}

5.9.7、单例模式的扩展

单例模式可扩展为有限的多例(Multitcm)模式,这种模式可生成有限个实例并保存在 ArrayList 中,客户需要时可随机获取,结构图:

image-20210412215700680

6、工厂模式(创建型设计模式)

在日常开发中,凡是需要生成复杂对象的地方,都可以尝试考虑使用工厂模式来代替。

注意:上述复杂对象指的是类的构造函数参数过多等对类的构造有影响的情况,因为类的构造过于复杂,如果直接在其他业务类内使用,则两者的耦合过重,后续业务更改,就需要在任何引用该类的源代码内进行更改,光是查找所有依赖就很消耗时间了,更别说要一个一个修改了。

工厂模式的定义:定义一个创建产品对象的工厂接口,将产品对象的实际创建工作推迟到具体子工厂类当中。这满足创建型模式中所要求的“创建与使用相分离”的特点。

按实际业务场景划分,工厂模式有 3 种不同的实现方式,分别是

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工厂模式

6.1、简单工厂模式SimpleFactory

6.1.1、简单工厂模式介绍

  1. 简单工厂模式是属于创建型模式,是工厂模式的一种。简单工厂模式是由一个工厂对象决定创建出哪一种产品类的实例。简单工厂模式是工厂模式家族中最简单实用的模式。
  2. 简单工厂模式:定义了一个创建对象的类,由这个类来封装实例化对象的行为(代码)
  3. 在软件开发中,当我们会用到大量的创建某种、某类或者某批对象时,就会使用到工厂模式。
  4. 我们把被创建的对象称为“产品”,把创建产品的对象称为“工厂”。如果要创建的产品不多,只要一个工厂类就可以完成,这种模式叫“简单工厂模式”。
  5. 在简单工厂模式中创建实例的方法通常为静态(static)方法,因此简单工厂模式(Simple Factory Pattern)又叫作静态工厂方法模式(Static Factory Method Pattern)。

可总结:

  1. 一个调用者想创建一个对象,只要知道其名称就可以了。
  2. 扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。
  3. 屏蔽产品的具体实现,调用者只关心产品的接口。

6.1.2、模式的结构与实现

简单工厂模式的主要角色如下:

  • 简单工厂(SimpleFactory):是简单工厂模式的核心,负责实现创建所有实例的内部逻辑。工厂类的创建产品类的方法可以被外界直接调用,创建所需的产品对象。
  • 抽象产品(Product):是简单工厂创建的所有对象的父类,负责描述所有实例共有的公共接口。
  • 具体产品(ConcreteProduct):是简单工厂模式的创建目标。

其结构图如下图所示:

image-20210413003915038

根据上图写出该模式的代码如下:(模板)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Client {
public static void main(String[] args) {
}
//抽象产品
public interface Product {
void show();
}
//具体产品:ProductA
static class ConcreteProduct1 implements Product {
public void show() {
System.out.println("具体产品1显示...");
}
}
//具体产品:ProductB
static class ConcreteProduct2 implements Product {
public void show() {
System.out.println("具体产品2显示...");
}
}
final class Const {
static final int PRODUCT_A = 0;
static final int PRODUCT_B = 1;
static final int PRODUCT_C = 2;
}
static class SimpleFactory {
public static Product makeProduct(int kind) {
switch (kind) {
case Const.PRODUCT_A:
return new ConcreteProduct1();
case Const.PRODUCT_B:
return new ConcreteProduct2();
}
return null;
}
}
}

6.1.3、应用实例

看一个具体的需求:披萨的项目:要便于披萨种类的扩展,要便于维护

  1. 披萨的种类很多(比如 GreekPizz、CheesePizz 等)
  2. 披萨的制作有 prepare,bake, cut, box
  3. 完成披萨店订购功能。

使用简单工厂模式实现:

简单工厂模式的设计方案: 定义一个可以实例化 Pizaa 对象的类,封装创建对象的代码。

image-20210413001450106

代码实现(省略pizza抽象类与具体实现类的编写):

根据简单工厂模式创建:

  1. 创建工厂类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //简单工厂类
    public class SimpleFactory {
    //根据orderType 返回对应的Pizza 对象
    public Pizza createPizza(String orderType) {
    Pizza pizza = null;
    if (orderType.equals("greek")) {
    pizza = new GreekPizza();
    pizza.setName(" 希腊披萨 ");
    } else if (orderType.equals("cheese")) {
    pizza = new CheesePizza();
    pizza.setName(" 奶酪披萨 ");
    } else if (orderType.equals("pepper")) {
    pizza = new PepperPizza();
    pizza.setName("胡椒披萨");
    }
    return pizza;
    }
    }
  2. 创建订购披萨类OrderPizza

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    public class OrderPizza {
    //定义一个简单工厂对象
    SimpleFactory simpleFactory;
    Pizza pizza = null;
    //构造器
    public OrderPizza(SimpleFactory simpleFactory) {
    setFactory(simpleFactory);
    }
    public void setFactory(SimpleFactory simpleFactory) {
    String orderType = ""; //用户输入的
    this.simpleFactory = simpleFactory; //设置简单工厂对象
    do {
    orderType = getType();
    pizza = this.simpleFactory.createPizza(orderType);
    //输出pizza
    if(pizza != null) { //订购成功
    pizza.prepare();
    pizza.bake();
    pizza.cut();
    pizza.box();
    } else {
    System.out.println(" 订购披萨失败 ");
    break;
    }
    }while(true);
    }
    // 写一个方法,可以获取客户希望订购的披萨种类
    private String getType() {
    try {
    BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("input pizza 种类:");
    String str = strin.readLine();
    return str;
    } catch (IOException e) {
    e.printStackTrace();
    return "";
    }
    }
    }
  3. 客户端PizzaStore:

    1
    2
    3
    4
    5
    6
    7
    8
    //相当于一个客户端,发出订购
    public class PizzaStore {
    public static void main(String[] args) {
    //使用简单工厂模式
    new OrderPizza(new SimpleFactory());
    System.out.println("~~退出程序~~");
    }
    }

根据静态工厂模式创建:

  1. 创建静态工厂类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class SimpleFactory {
    //简单工厂模式 也叫 静态工厂模式
    public static Pizza createPizza(String orderType) {
    Pizza pizza = null;
    if (orderType.equals("greek")) {
    pizza = new GreekPizza();
    pizza.setName(" 希腊披萨 ");
    } else if (orderType.equals("cheese")) {
    pizza = new CheesePizza();
    pizza.setName(" 奶酪披萨 ");
    } else if (orderType.equals("pepper")) {
    pizza = new PepperPizza();
    pizza.setName("胡椒披萨");
    }
    return pizza;
    }
    }
  2. 创建订购披萨类OrderPizza

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    public class OrderPizza {
    Pizza pizza = null;
    String orderType = "";
    // 构造器
    public OrderPizza() {
    do {
    orderType = getType();
    pizza = SimpleFactory.createPizza2(orderType);
    // 输出pizza
    if (pizza != null) { // 订购成功
    pizza.prepare();
    pizza.bake();
    pizza.cut();
    pizza.box();
    } else {
    System.out.println(" 订购披萨失败 ");
    break;
    }
    } while (true);
    }
    // 写一个方法,可以获取客户希望订购的披萨种类
    private String getType() {
    try {
    BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("input pizza 种类:");
    String str = strin.readLine();
    return str;
    } catch (IOException e) {
    e.printStackTrace();
    return "";
    }
    }
    }
  3. 客户端PizzaStore:

    1
    2
    3
    4
    5
    6
    7
    8
    //相当于一个客户端,发出订购
    public class PizzaStore {
    public static void main(String[] args) {
    //使用静态工厂模式
    new OrderPizza();
    System.out.println("~~退出程序~~");
    }
    }

6.1.4、简单工厂模式(静态工厂模式)的相关说明

  • 优点:

    1. 工厂类包含必要的逻辑判断,可以决定在什么时候创建哪一个产品的实例。客户端可以免除直接创建产品对象的职责,很方便的创建出相应的产品。工厂和产品的职责区分明确。
    2. 客户端无需知道所创建具体产品的类名,只需知道参数即可。
    3. 也可以引入配置文件,在不修改客户端代码的情况下更换和添加新的具体产品类
  • 缺点:

    1. 简单工厂模式的工厂类单一,负责所有产品的创建,职责过重,一旦异常,整个系统将受影响。且工厂类代码会非常臃肿违背高聚合原则
    2. 使用简单工厂模式会增加系统中类的个数(引入新的工厂类),增加系统的复杂度和理解难度
    3. 系统扩展困难,一旦增加新产品不得不修改工厂逻辑,在产品类型较多时,可能造成逻辑过于复杂
    4. 简单工厂模式使用了 static 工厂方法,造成工厂角色无法形成基于继承的等级结构。
  • 应用场景

    对于产品种类相对较少的情况,考虑使用简单工厂模式。使用简单工厂模式的客户端只需要传入工厂类的参数,不需要关心如何创建对象的逻辑,可以很方便地创建所需产品。

  • 简单来说,简单工厂模式有一个具体的工厂类,可以生成多个不同的产品,属于创建型设计模式。简单工厂模式不在 GoF 23 种设计模式之列。

6.2、工厂方法模式Factory Method

image-20210415031807022

6.2.1、工厂方法模式介绍

  1. 简单工厂模式提到了违背了开闭原则,而“工厂方法模式”是对简单工厂模式的进一步抽象化,其好处是可以使系统在不修改原来代码的情况下引进新的产品,即满足开闭原则
  2. 工厂方法模式:定义了一个创建对象的抽象方法,由子类决定要实例化的类。工厂方法模式将对象的实例化推迟到子类。

6.2.2、模式的结构与实现

工厂方法模式由抽象工厂、具体工厂、抽象产品和具体产品等4个要素构成。本节来分析其基本结构和实现方法。

6.2.2.1. 模式的结构

工厂方法模式的主要角色如下。

  1. 抽象工厂(Abstract Factory):提供了创建产品的接口,调用者通过它访问具体工厂的工厂方法 newProduct() 来创建产品。
  2. 具体工厂(ConcreteFactory):主要是实现抽象工厂中的抽象方法,完成具体产品的创建。
  3. 抽象产品(Product):定义了产品的规范,描述了产品的主要特性和功能。
  4. 具体产品(ConcreteProduct):实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间一一对应。

其结构图如图 1 所示:

image-20210413004548795

6.2.2.2、模式的实现

根据图 1 写出该模式的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package FactoryMethod;
public class AbstractFactoryTest {
public static void main(String[] args) {
try {
Product a;
AbstractFactory af;
af = (AbstractFactory) ReadXML1.getObject();
a = af.newProduct();
a.show();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
//抽象产品:提供了产品的接口
interface Product {
public void show();
}
//具体产品1:实现抽象产品中的抽象方法
class ConcreteProduct1 implements Product {
public void show() {
System.out.println("具体产品1显示...");
}
}
//具体产品2:实现抽象产品中的抽象方法
class ConcreteProduct2 implements Product {
public void show() {
System.out.println("具体产品2显示...");
}
}
//抽象工厂:提供了厂品的生成方法
interface AbstractFactory {
public Product newProduct();
}
//具体工厂1:实现了厂品的生成方法
class ConcreteFactory1 implements AbstractFactory {
public Product newProduct() {
System.out.println("具体工厂1生成-->具体产品1...");
return new ConcreteProduct1();
}
}
//具体工厂2:实现了厂品的生成方法
class ConcreteFactory2 implements AbstractFactory {
public Product newProduct() {
System.out.println("具体工厂2生成-->具体产品2...");
return new ConcreteProduct2();
}
}

6.2.3、应用实例

  1. 披萨项目新的需求:客户在点披萨时,可以点不同口味的披萨,比如 北京的奶酪 pizza、北京的胡椒 pizza 或者是伦敦的奶酪 pizza、伦敦的胡椒 pizza

  2. 思路分析图解:

    image-20210413012849728

  3. 代码实现:

    订购披萨OrderPizza

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    public abstract class OrderPizza {
    //定义一个抽象方法,createPizza , 让各个工厂子类自己实现
    abstract Pizza createPizza(String orderType);
    // 构造器
    public OrderPizza() {
    Pizza pizza = null;
    String orderType; // 订购披萨的类型
    do {
    orderType = getType();
    pizza = createPizza(orderType); //抽象方法,由工厂子类完成
    //输出pizza 制作过程
    pizza.prepare();
    pizza.bake();
    pizza.cut();
    pizza.box();
    } while (true);
    }
    // 写一个方法,可以获取客户希望订购的披萨种类
    private String getType() {
    try {
    BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
    System.out.println("input pizza 种类:");
    String str = strin.readLine();
    return str;
    } catch (IOException e) {
    e.printStackTrace();
    return "";
    }
    }
    }

    北京的pizza继承OrderPizza(伦敦同)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class BJOrderPizza extends OrderPizza {
    @Override
    Pizza createPizza(String orderType) {
    Pizza pizza = null;
    if(orderType.equals("cheese")) {
    pizza = new BJCheesePizza();
    } else if (orderType.equals("pepper")) {
    pizza = new BJPepperPizza();
    }
    // TODO Auto-generated method stub
    return pizza;
    }
    }

    客户端:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public class PizzaStore {
    public static void main(String[] args) {
    String loc = "bj";
    if (loc.equals("bj")) {
    //创建北京口味的各种Pizza
    new BJOrderPizza();
    } else {
    //创建伦敦口味的各种Pizza
    new LDOrderPizza();
    }
    }
    }

6.2.4、工厂方法模式的相关说明

  • 优点:

    1. 用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程。
    2. 灵活性增强,对于新产品的创建,只需多写一个相应的工厂类
    3. 典型的解耦框架。高层模块只需要知道产品的抽象类,无须关心其他实现类,满足迪米特法则、依赖倒置原则和里氏替换原则。
  • 缺点:

    1. 类的个数容易过多,增加复杂度
    2. 增加了系统的抽象性和理解难度
    3. 抽象产品只能生产一种产品,此弊端可使用抽象工厂模式解决。
  • 应用场景:

    1. 客户只知道创建产品的工厂名,而不知道具体的产品名。如 TCL 电视工厂、海信电视工厂等。
    2. 创建对象的任务由多个具体子工厂中的某一个完成,而抽象工厂只提供创建产品的接口。
    3. 客户不关心创建产品的细节,只关心产品的品牌
  • 注意:

    当需要生成的产品不多且不会增加,一个具体工厂类就可以完成任务时,可删除抽象工厂类。这时工厂方法模式将退化到简单工厂模式。

6.2.5、工厂方法模式的登场角色补充(来自《图解设计模式》)

在 Factory Method 模式中有以下登场角色。 通过查看 Factory Method 模式的类图,我们可以知道, 父类(框架)这一方的 Creator 角色Product 角色的关系子类(具体加工)这一方的 ConcreteCreator 角色和 ConcreteProduct 角色的关系是平行的

image-20210413023513528

  • Product (产品)

    Product角色属于框架这一方, 是一个抽象类。它定义了在Factory Method模式中生成的那些实例所持有的接口(API), 但具体的处理则由子类ConcreteProduct角色决定。 在示例程序中由Product类扮演此角色。

  • Creator (创建者)

    Creator角色属千框架这一方, 它是负责生成 Product角色的抽象类,但具体的处理则由子类ConcreteCreator角色决定。 在示例程序中, 由Factory类扮演此角色。

  • Creator角色对于实际负责生成实例的ConcreteCreator角色一无所知,它唯一知道的就是, 只要调用Product角色和生成实例的方法(图4-3中的factoryMethod 方法), 就可以生成Productde的实例。 在示例程序中,createProduct 方法是用于生成实例的方法。 不用new关键字来生成实例, 而是调用生成实例的专用方法来生成实例, 这样就可以防止父类与其他具体类耦合。

  • ConcreteProduct (具体的产品)

    Concrete Product角色属于具体加工这一方,它决定了具体的产品。 在示例程序中, 由IDCard 类扮演此角色。

  • ConcreteCreator (具体的创建者)

    ConcreteCreator角色属于具体加工这一方, 它负责生成具体的产品。 在示例程序中,由IDCardFactory类扮演此角色。

6.2.6、相关的设计模式

  • Template Method 模式

    Factory Method模式是Template Method的典型应用。在示例程序中, create方法就是模板方法。

  • Singleton 模式

    在多数情况下我们都可以将Singleton模式用于扮演Creator角色(或是ConcreteCreator角色) 的类。这是因为在程序中没有必要存在多个 Creator角色(或是ConcreteCreator角色)的实例。不过在示例程序中, 我们并没有使用Singleton模式。

  • Composite 模式

    有时可以将 Composite模式用于Product角色(或是ConcreteProduct角色)。

  • Iterator 模式

    有时, 在Iterator模式中使用iterator方法生成Iterator的实例时会使用Factory Method 模式。

6.3、抽象工厂模式Abstract Factory

image-20210415032040170

6.3.1、抽象工厂模式介绍

  1. 前面介绍的工厂方法模式中考虑的是一类产品的生产,如畜牧场只养动物、电视机厂只生产电视机、计算机软件学院只培养计算机软件专业的学生等。
  2. 同种类称为同等级,也就是说:工厂方法模式只考虑生产同等级的产品,但是在现实生活中许多工厂是综合型的工厂,能生产多等级(种类) 的产品,如农场里既养动物又种植物,电器厂既生产电视机又生产洗衣机或空调,大学既有软件专业又有生物专业等。
  3. 抽象工厂模式将考虑多等级产品的生产,将同一个具体工厂所生产的位于不同等级的一组产品称为一个产品族.

抽象工厂(AbstractFactory)模式的定义:定义了一个 interface 用于创建相关或有依赖关系的对象簇,而无需指明具体的类,是一种为访问类提供一个创建一组相关或相互依赖对象的接口,且访问类无须指定所要产品的具体类就能得到同族的不同等级的产品的模式结构。可以看作:抽象工厂是一个超级工厂,围绕一个超级工厂创建其他工厂,该超级工厂又称为其他工厂的工厂。

抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可生产多个等级的产品。抽象工厂模式可以将简单工厂模式和工厂方法模式进行整合。

从设计层面看,抽象工厂模式就是对简单工厂模式的改进(或者称为进一步的抽象)。

将工厂抽象成两层,AbsFactory(**抽象工厂)** 和 具体实现的工厂子类。程序员可以根据创建对象类型使用对应的工厂子类。这样将单个的简单工厂类变成了工厂簇,更利于代码的维护和扩展。

使用抽象工厂模式一般要满足以下条件。

  • 系统中有多个产品族,每个具体工厂创建同一族但属于不同等级结构的产品。
  • 系统一次只可能消费其中某一族产品,即同族的产品一起使用。

6.3.2、模式的结构与实现

6.3.2.1、模式的结构

抽象工厂模式的主要角色如下。

  1. 抽象工厂(Abstract Factory):提供了创建产品的接口,它包含多个创建产品的方法 newProduct(),可以创建多个不同等级的产品。
  2. 具体工厂(Concrete Factory):主要是实现抽象工厂中的多个抽象方法,完成具体产品的创建。
  3. 抽象产品(Product):定义了产品的规范,描述了产品的主要特性和功能,抽象工厂模式有多个抽象产品。
  4. 具体产品(ConcreteProduct):实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间是多对一的关系。
6.3.2.2、模式的实现类图(具体代码

image-20210413015123090

6.3.3、应用实例

使用抽象工厂模式来完成披萨项目。

类图:

image-20210413015603989

代码:

抽象工厂:

1
2
3
4
5
//一个抽象工厂模式的抽象层(接口)
public interface AbsFactory {
//让下面的工厂子类来 具体实现
public Pizza createPizza(String orderType);
}

北京工厂实现抽象工厂生产披萨(伦敦同)

1
2
3
4
5
6
7
8
9
10
11
12
13
//这是工厂子类
public class BJFactory implements AbsFactory {
@Override
public Pizza createPizza(String orderType) {
Pizza pizza = null;
if(orderType.equals("cheese")) {
pizza = new BJCheesePizza();
} else if (orderType.equals("pepper")){
pizza = new BJPepperPizza();
}
return pizza;
}
}

订购披萨:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class OrderPizza {
AbsFactory factory;
// 构造器
public OrderPizza(AbsFactory factory) {
setFactory(factory);
}
private void setFactory(AbsFactory factory) {
Pizza pizza = null;
String orderType = ""; // 用户输入
this.factory = factory;
do {
orderType = getType();
// factory 可能是北京的工厂子类,也可能是伦敦的工厂子类
pizza = factory.createPizza(orderType);
if (pizza != null) { // 订购ok
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
} else {
System.out.println("订购失败");
break;
}
} while (true);
}
// 写一个方法,可以获取客户希望订购的披萨种类
private String getType() {
try {
BufferedReader strin = new BufferedReader(new InputStreamReader(System.in));
System.out.println("input pizza 种类:");
String str = strin.readLine();
return str;
} catch (IOException e) {
e.printStackTrace();
return "";
}
}
}

客户端:

1
2
3
4
5
6
7
public class PizzaStore {
public static void main(String[] args) {
new OrderPizza(new BJFactory());
// new OrderPizza(new LDFactory());
}

}

6.3.4、抽象工厂模式的相关说明

  • 抽象工厂模式除了具有工厂方法模式的优点外,其他主要优点如下。

    1. 可以在类的内部对产品族中相关联的多等级产品共同管理,而不必专门引入多个新的类来进行管理。
    2. 当需要产品族时,抽象工厂可以保证客户端始终只使用同一个产品的产品组
    3. 抽象工厂增强了程序的可扩展性,当增加一个新的产品族时,不需要修改原代码,满足开闭原则
  • 其缺点是:当产品族中需要增加一个新的产品时,所有的工厂类都需要进行修改。增加了系统的抽象性和理解难度

    模式的应用场景

    抽象工厂模式最早的应用是用于创建属于不同操作系统的视窗构件。如 Java 的 AWT 中的 Button 和 Text 等构件在 Windows 和 UNIX 中的本地实现是不同的。

    1. 当需要创建的对象是一系列相互关联或相互依赖的产品族时,如电器工厂中的电视机、洗衣机、空调等。
    2. 系统中有多个产品族,但每次只使用其中的某一族产品。如有人只喜欢穿某一个品牌的衣服和鞋。
    3. 系统中提供了产品的类库,且所有产品的接口相同,客户端不依赖产品实例的创建细节和内部结构。
  • 模式的扩展

    抽象工厂模式的扩展有一定的“开闭原则”倾斜性:

    1. 当增加一个新的产品族时只需增加一个新的具体工厂,不需要修改原代码,满足开闭原则。
    2. 当产品族中需要增加一个新种类的产品时,则所有的工厂类都需要进行修改,不满足开闭原则。

    另一方面,当系统中只存在一个等级结构的产品时,抽象工厂模式将退化到工厂方法模式。

  • 进阶阅读

    如果您想了解抽象工厂在框架源码中的应用,可阅读以下文章。

6.3.5、相关的设计模式

  • Builder模式

    Abstract Factory模式通过调用抽象产品的接口 (APl) 来组装抽象产品, 生成具有复杂结构的实例。

    Builder模式则是分阶段地制作复杂实例。

  • Factory Method模式

    有时AbstractFactory模式中零件和产品的生成会使用到Factory Method模式。

  • Composite模式

    有时AbstractFactory模式在制作产品时会使用Composite模式。

  • Singleton模式

    有时AbstractFactory模式中的具体工厂会使用Singleton模式。

6.4、工厂模式在 JDK-Calendar 应用的源码分析

JDK 中的 Calendar 类中,就使用了简单工厂模式

原码:

image-20210413021425940

其中createCalendar()方法:

image-20210413022320157

6.5、工厂模式小结

  1. 工厂模式的意义

    将实例化对象的代码提取出来,放到一个类中统一管理和维护,达到和主项目的依赖关系的解耦。从而提高项目的扩展和维护性。

  2. 三种工厂模式

    1. 简单工厂模式(不在23种之中)
    2. 工厂方法模式
    3. 抽象工厂模式
  3. 设计模式的依赖抽象原则

创建对象实例时,不要直接 new 类, 而是把这个 new 类的动作放在一个工厂的方法中,并返回。有的书上说, 变量不要直接持有具体类的引用。

不要让类继承具体类,而是继承抽象类或者是实现 interface(接口)

不要覆盖基类中已经实现的方法。

7、原型模式ProtoType(创建型设计模式)

image-20210415031921912

7.1、基本介绍

  1. 原型模式(Prototype 模式)是指:用原型实例指定创建对象的种类,并且通过拷贝这些原型,创建新的对象
  2. 原型模式是一种创建型设计模式,允许一个对象再创建另外一个可定制的对象,无需知道如何创建的细节
  3. 工作原理是:通过将一个原型对象传给那个要发动创建的对象,这个要发动创建的对象通过请求原型对象拷贝它们自己来实施创建,即 对象**.clone**()

7.2、原型模式原理结构图-uml 类图

image-20210413222550496

原理结构图说明:

  • Prototype : 原型类,声明一个克隆自己的接口
  • ConcretePrototype: 具体的原型类, 实现一个克隆自己的操作
  • Client: 使用者;让一个原型对象克隆自己,从而创建一个新的对象(属性一样)

7.3、应用举例

克隆羊问题:

现在有一只羊 tom,姓名为: tom, 年龄为:1,颜色为:白色,请编写程序创建和 tom 羊 属性完全相同的 10只羊。

7.3.1、传统方式解决克隆羊问题

7.3.1.1、思路分析(类图)

image-20210413222817657

7.3.1.2、相关代码:(在Client中)
1
2
3
//传统的方法
Sheep sheep = new Sheep("tom", 1, "白色");
Sheep sheep2 = new Sheep(sheep.getName(), sheep.getAge(), sheep.getColor());
7.3.1.3、传统的方式的优缺点
  1. 优点是比较好理解,简单易操作。
  2. 在创建新的对象时,总是需要重新获取原始对象的属性,如果创建的对象比较复杂时,效率较低
  3. 总是需要重新初始化对象,而不是动态地获得对象运行时的状态, 不够灵活
7.3.1.4、改进方法(使用原型模式)

Java 中 Object 类是所有类的根类,Object 类提供了一个 clone()方法,该方法可以将一个 Java 对象复制一份,但是需要实现 clone 的 Java 类必须要实现一个接口 Cloneable,该接口表示该类能够复制且具有复制的能力 =>原型模式

7.3.2、原型模式解决克隆羊问题

实现步骤:

  1. 实例实现接口Cloneable,并重写Object的clone方法
  2. 在Client使用创建的实例的clone方法进行对象的克隆

代码实现:

sheep:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Sheep implements Cloneable {
private String name;
private int age;
private String color;
private String address = "蒙古羊";
public Sheep friend; //是对象, 克隆是会如何处理
public Sheep(String name, int age, String color) {
super();
this.name = name;
this.age = age;
this.color = color;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}
@Override
public String toString() {
return "Sheep [name=" + name + ", age=" + age + ", color=" + color + ", address=" + address + "]";
}
//克隆该实例,使用默认的clone方法来完成
@Override
protected Object clone() {
Sheep sheep = null;
try {
sheep = (Sheep)super.clone();
} catch (Exception e) {
System.out.println(e.getMessage());
}
return sheep;
}
}

Client:

1
2
3
4
5
6
7
8
9
public class Client {
public static void main(String[] args) {
Sheep sheep = new Sheep("tom", 1, "白色");
sheep.friend = new Sheep("jack", 2, "黑色");
//克隆
Sheep sheep2 = (Sheep)sheep.clone();
System.out.println("sheep2 =" + sheep2 + "sheep2.friend=" + sheep2.friend.hashCode());
}
}

使用原型模式改进传统方式,让程序具有更高的效率和扩展性

7.4、浅拷贝和深拷贝

7.4.1、浅拷贝的介绍

  1. 对于数据类型是基本数据类型的成员变量,浅拷贝会直接进行值传递,也就是将该属性值复制一份给新的对象
  2. 对于String,虽然String不是基本数据结构,而是应用数据类型。但是在JVM中存在字符串常量池会存储已创建的字符串。在克隆的时候也是引用也是直接指向字符串常量池里的字符串。所以在clone当中可以将String近似于看作基本数据类型。
  3. 对于数据类型是引用数据类型的成员变量,比如说成员变量是某个数组、某个类的对象等,那么浅拷贝会进行引用传递,也就是只是将该成员变量的引用值(内存地址)复制一份给新的对象。因为实际上两个对象的该成员变量都指向同一个实例。在这种情况下,在一个对象中修改该成员变量会影响到另一个对象的该成员变量值
  4. 前面我们克隆羊就是浅拷贝(里面的friend是同一个friend,即所有的克隆羊都有同一个朋友)
  5. 浅拷贝是使用默认的 clone()方法来实现:sheep = (Sheep) super.clone();

7.4.2、深拷贝基本介绍

  1. 复制对象的所有基本数据类型的成员变量值
  2. 所有引用数据类型的成员变量申请存储空间,并复制每个引用数据类型成员变量所引用的对象,直到该对象可达的所有对象。也就是说,对象进行深拷贝要对整个对象(包括对象的引用类型)进行拷贝
  3. 深拷贝实现方式 1:重写 clone 方法来实现深拷贝
  4. 深拷贝实现方式 2:通过对象序列化实现深拷贝(推荐)

7.4.3、深拷贝应用实例

7.4.3.1、使用 重写 clone 方法实现深拷贝

DeepCloneableTarget:其他实例当中的成员变量:实现克隆接口与序列化接口Serializable, Cloneable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DeepCloneableTarget implements Serializable, Cloneable {
private static final long serialVersionUID = 1L;
private String cloneName;
private String cloneClass;
//构造器
public DeepCloneableTarget(String cloneName, String cloneClass) {
this.cloneName = cloneName;
this.cloneClass = cloneClass;
}
//因为该类的属性,都是String , 因此我们这里使用默认的clone完成即可
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}

DeepProtoType:要进行克隆的实例类,其中有成员变量DeepCloneableTarget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DeepProtoType implements Serializable, Cloneable{
public String name; //String 属性
public DeepCloneableTarget deepCloneableTarget;// 引用类型
public DeepProtoType() {
super();
}
//深拷贝 - 方式 1 使用clone 方法
@Override
protected Object clone() throws CloneNotSupportedException {
Object deep = null;
//这里完成对基本数据类型(属性)和String的克隆
deep = super.clone();
//对引用类型的属性,进行单独处理
DeepProtoType deepProtoType = (DeepProtoType)deep;
deepProtoType.deepCloneableTarget = (DeepCloneableTarget)deepCloneableTarget.clone();
return deepProtoType;
}
}

Client:对DeepProtoType进行克隆

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws Exception {
DeepProtoType p = new DeepProtoType();
p.name = "宋江";
p.deepCloneableTarget = new DeepCloneableTarget("大牛", "小牛");
//方式1 完成深拷贝
DeepProtoType p2 = (DeepProtoType) p.clone();

System.out.println("p.name=" + p.name + "p.deepCloneableTarget=" + p.deepCloneableTarget.hashCode());
System.out.println("p2.name=" + p.name + "p2.deepCloneableTarget=" + p2.deepCloneableTarget.hashCode());
}
}
7.4.3.2、使用序列化来实现深拷贝

DeepCloneableTarget:同上

DeepProtoType:要进行克隆的实例类,其中有成员变量DeepCloneableTarget

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class DeepProtoType implements Serializable, Cloneable{
public String name; //String 属性
public DeepCloneableTarget deepCloneableTarget;// 引用类型
public DeepProtoType() {
super();
}
//深拷贝 - 方式2 通过对象的序列化实现 (推荐)
public Object deepClone() {
//创建流对象
ByteArrayOutputStream bos = null;
ObjectOutputStream oos = null;
ByteArrayInputStream bis = null;
ObjectInputStream ois = null;
try {
//序列化
bos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(bos);
oos.writeObject(this); //当前这个对象以对象流的方式输出
//反序列化
bis = new ByteArrayInputStream(bos.toByteArray());
ois = new ObjectInputStream(bis);
DeepProtoType copyObj = (DeepProtoType)ois.readObject();
return copyObj;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
//关闭流
try {
bos.close();
oos.close();
bis.close();
ois.close();
} catch (Exception e2) {
System.out.println(e2.getMessage());
}
}
}
}

Client:对DeepProtoType进行克隆

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws Exception {
// TODO Auto-generated method stub
DeepProtoType p = new DeepProtoType();
p.name = "宋江";
p.deepCloneableTarget = new DeepCloneableTarget("大牛", "小牛");
//方式2 完成深拷贝
DeepProtoType p2 = (DeepProtoType) p.deepClone();
System.out.println("p.name=" + p.name + "p.deepCloneableTarget=" + p.deepCloneableTarget.hashCode());
System.out.println("p2.name=" + p.name + "p2.deepCloneableTarget=" + p2.deepCloneableTarget.hashCode());
}
}
7.4.3.3、对于实例类的成员变量为本身的实例的深拷贝:

使用序列化可以实现,但是使用clone方法会报StackOverflowError异常

sheep:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class Sheep implements Cloneable, Serializable {
private String name;
private int age;
private String color;
//实例类的成员变量为本身
public Sheep friend;
public Sheep(String name, int age, String color) {
super();
this.name = name;
this.age = age;
this.color = color;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public String getColor() {
return color;
}
public void setColor(String color) {
this.color = color;
}

@Override
public String toString() {
return "Sheep{" +
"name='" + name + '\'' +
", age=" + age +
", color='" + color + '\'' +
", friend=" + friend +
'}';
}
//深拷贝 通过对象的序列化实现
public Object deepClone() {
//创建流对象
ByteArrayOutputStream bos = null;
ObjectOutputStream oos = null;
ByteArrayInputStream bis = null;
ObjectInputStream ois = null;
try {
//序列化
bos = new ByteArrayOutputStream();
oos = new ObjectOutputStream(bos);
oos.writeObject(this); //当前这个对象以对象流的方式输出
//反序列化
bis = new ByteArrayInputStream(bos.toByteArray());
ois = new ObjectInputStream(bis);
Sheep copyObj = (Sheep)ois.readObject();
return copyObj;
} catch (Exception e) {
e.printStackTrace();
return null;
} finally {
//关闭流
try {
bos.close();
oos.close();
bis.close();
ois.close();
} catch (Exception e2) {
System.out.println(e2.getMessage());
}
}
}
}

Client:

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) throws Exception {
Sheep sheep = new Sheep("Tom",1,"black");
sheep.friend = new Sheep("john", 2, "white");

Sheep sheep1 = (Sheep) sheep.deepClone();

System.out.println("sheep.name=" + sheep.getName() + " sheep.age=" + sheep.getAge() + " sheep.color=" + sheep.getColor() + " sheep.friend" + sheep.friend.hashCode());
System.out.println("sheep1.name=" + sheep1.getName() + " sheep1.age=" + sheep1.getAge() + " sheep1.color=" + sheep1.getColor() + " sheep1.friend" + sheep1.friend.hashCode());
System.out.println(sheep.friend == sheep1.friend);
}
}

7.4.4、对于深拷贝的clone方法与序列化方法

7.4.4.1、clone方法
  • clone方法分成两步:
    1. 先克隆基本数据类型和String
    2. 在对其引用数据类型进行多次克隆
  • 如果想要深拷贝一个对象, 这个对象必须要实现Cloneable接口,实现clone方法,并且在clone方法内部,把该对象引用的其他对象也要clone一份 , 这就要求这个被引用的对象必须也要实现Cloneable接口并且实现clone方法。
  • clone实际上就是实现了多重clone,实例本身有其他的应用数据类型(除String),就先重写其他的引用数据类型的clone方法;若在其他的应用数据类型(除String)又有其他的引用数据类型,又重复该过程,直到做到所有的成员变量都完成clone。
  • 所以,如果在拷贝一个对象时,要想让这个拷贝的对象和源对象完全彼此独立,那么在引用链上的每一级对象都要被显式的拷贝。所以创建彻底的深拷贝是非常麻烦的,尤其是在引用关系非常复杂的情况下, 或者在引用链的某一级上引用了一个第三方的对象, 而这个对象没有实现clone方法, 那么在它之后的所有引用的对象都是被共享的。或者如果某一个类没有实现Cloneable接口,我们还要对其进行深拷贝的话,就必然需要修改该类,这样就违反了OCP原则
  • 所以在开发中这种深拷贝方式不常用
7.4.4.2、序列化方法
  • 序列化方法也分成两步
    1. 将要实现克隆的实例进行序列化
    2. 在将其进行反序列化出来实现实例的拷贝
  • 使用该类的对象必须要实现Serializable接口,否则是没有办法实现克隆的。无须继承Cloneable接口实现clone()方法。
  • 在内存中通过字节流的拷贝是比较容易实现的。把母对象写入到一个字节流中,再从字节流中将其读出来,这样就可以创建一个新的对象了,并且该新对象与母对象之间并不存在引用共享的问题,真正实现对象的深拷贝
  • 能实现对于实例类的成员变量为本身的实例的深拷贝
  • 缺点:使用该类的对象必须要实现Serializable接口,所以在一些类并没有实现Serializable接口,如果还要对其进行深拷贝的话,就必然需要修改该类,这样就违反了OCP原则
  • 所以在开发中推荐使用这种方式进行深拷贝。

7.5、原型模式在 Spring 框架中源码分析

Spring 中原型 bean 的创建,就是原型模式的应用

image-20210414025832896

image-20210414030430256

7.6、new一个对象的过程和clone一个对象的过程区别

关于new:

new操作符的本意是分配内存。程序执行到new操作符时,会先去看new操作符后面的类型,因为知道了类型,才能知道要分配多大的内存空间。分配内存之后,再调用构造函数,填充对象的各个域,这一步叫做对象的初始化,构造方法返回后,一个对象创建完毕,可以把它的引用(也就是地址)发布到外部,在外部就可以使用这个引用操作这个对象。

关于clone:

clone在第一步是和new相似的,都是分配内存,调用clone方法时,分配的内存和原对象(即调用clone方法的对象)相同,然后再使用原对象中对应的各个域,填充新对象的域,填充完成之后,clone方法返回,一个新的相同的对象被创建,同样可以把这个新对象的引用发布到外部

  1. clone()不会调用构造方法;new会调用构造方法。
  2. new对象时根据类型确定分配内存空间的大小, clone是根据原对象分配内

7.7、原型模式的总结

原型模式的优点:

  • Java 自带的原型模式基于内存二进制流的复制,在性能上比直接 new 一个对象更加优良
  • 逃避了构造函数的约束
  • 可以使用深克隆方式保存对象的状态,使用原型模式将对象复制一份,并将其状态保存起来,简化了创建对象的过程,以便在需要的时候使用(例如恢复到历史某一状态),可辅助实现撤销操作

原型模式的缺点:

  • 需要为每一个类都配置一个 clone 方法
  • clone 方法位于类的内部,当对已有类进行改造的时候,需要修改代码,违背了开闭原则
  • 当实现深克隆时,需要编写较为复杂的代码,而且当对象之间存在多重嵌套引用时,为了实现深克隆,每一层对象对应的类都必须支持深克隆,实现起来会比较麻烦。因此,深克隆、浅克隆需要运用得当。

原型模式的应用场景:

  • 对象之间相同或相似,即只是个别的几个属性不同的时候。
  • 创建对象成本较大,例如初始化时间长,占用CPU太多,或者占用网络资源太多等,需要优化资源。类初始化需要消化非常多的资源,这个资源包括数据、硬件资源等。
  • 创建一个对象需要繁琐的数据准备或访问权限等,需要提高性能或者提高安全性。
  • 一个对象需要提供给其他对象访问,而且各个调用者可能都需要修改其值时,可以考虑使用原型模式拷贝多个对象供调用者使用
  • 资源优化场景
  • 一个对象多个修改者的场景
  • 想要生成实例的框架不依赖与具体的类,解耦框架与生成的实例

在实际项目中,原型模式很少单独出现,一般是和工厂方法模式一起出现,通过 clone 的方法创建一个对象,然后由工厂方法提供给调用者。原型模式已经与 Java 融为浑然一体,大家可以随手拿来使用。

Spring 中,原型模式应用的非常广泛,例如 scope=’prototype’、JSON.parseObject() 等都是原型模式的具体应用。

7.8、原型模式的扩展(带原型管理器的原型模式)

原型模式可扩展为带原型管理器的原型模式,它在原型模式的基础上增加了一个原型管理器 PrototypeManager 类。该类用 HashMap 保存多个复制的原型,Client 类可以通过管理器的 get(String id) 方法从中获取复制的原型。其结构图:

image-20210414092155079

举例:

用带原型管理器的原型模式来生成包含“圆”和“正方形”等图形的原型,并计算其面积。分析:本实例中由于存在不同的图形类,例如,“圆”和“正方形”,它们计算面积的方法不一样,所以需要用一个原型管理器来管理它们,是其结构图:

image-20210414092311712

ProtoTypeManager :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ProtoTypeManager {
private HashMap<String, Shape> ht = new HashMap<String, Shape>();
public ProtoTypeManager() {
ht.put("Circle", new Circle());
ht.put("Square", new Square());
}
public void addshape(String key, Shape obj) {
ht.put(key, obj);
}
public Shape getShape(String key) {
Shape temp = ht.get(key);
return (Shape) temp.clone();
}
}

7.9、进阶阅读

原型模式也称为克隆模式,如果您想深入了解原型(克隆)模式,可以猛击阅读下面的文章。

7.10、相关的设计模式

  • Flyweight 模式

    使用Prototype模式可以生成一个与当前实例的状态完全相同的实例。 而使用Flyweight模式可以在不同的地方使用同一个实例。

  • Memento 模式

    使用Prototype模式可以生成一个与当前实例的状态完全相同的实例。而使用Memento模式可以保存当前实例的状态, 以实现快照和撤销功能。

  • Composite 模式以及 Decorator 模式

    经常使用Composite模式和Decorator模式时, 需要能够动态地创建复杂结构的实例。 这时可 以使用Prototype模式, 以帮助我们方便地生成实例。

  • Command 模式

    想要复制Command模式中出现的命令时, 可以使用Prototype模式。

7.11、原型模式的注意事项和细节

  • 创建新的对象比较复杂时,可以利用原型模式简化对象的创建过程,同时也能够提高效率
  • 不用重新初始化对象,而是动态地获得对象运行时的状态
  • 如果原始对象发生变化(增加或者减少属性),其它克隆对象的也会发生相应的变化,无需修改代码
  • 在实现深克隆的时候可能需要比较复杂的代码
  • Cloneable接口是一个标记接口,没有声明方法
  • 缺点:需要为每一个类配备一个克隆方法,这对全新的类来说不是很难,但对已有的类进行改造时,需要修改其源代码,违背了 ocp 原则。
  • 原型模式应用不是很广泛,因为很多实例会持有类似文件、Socket这样的资源,而这些资源是无法复制给另一个对象共享的,只有存储简单类型的“值”对象可以复制。

8、建造者模式Builder(创建型设计模式)

image-20210415031952053

8.1、基本介绍

  1. 建造者模式(Builder Pattern) 又叫生成器模式,是一种对象构建模式。它可以将复杂对象的建造过程抽象出来(抽象类别),使这个抽象过程的不同实现方法可以构造出不同表现(属性)的对象
  2. 建造者模式将一个复杂对象的构造与它的表示分离,使同样的构建过程可以创建不同的表示。它是将一个复杂的对象分解为多个简单的对象,然后一步一步构建而成。它将变与不变相分离,即产品的组成部分不变的,但每一部分是可以灵活选择的。

8.2、建造者模原理结构图-uml类图与模板实现

8.2.1、建造者模式的四个角色

建造者(Builder)模式由产品抽象建造者具体建造者指挥者等 4 个要素构成

  1. Product(产品角色): 它是包含多个组成部件的复杂对象,由具体建造者来创建其各个零部件
  2. Builder(抽象建造者): 它是一个包含创建产品各个子部件的抽象方法的接口,通常还包含一个返回**复杂产品的方法 getResult()**。
  3. ConcreteBuilder(具体建造者): 实现 Builder 接口,完成复杂产品的各个部件的具体创建方法。
  4. Director(指挥者): 构建一个使用 Builder 接口的对象。它主要是用于创建一个复杂的对象。它主要有两个作用:
    1. 隔离了客户与对象的生产过程
    2. 负责控制产品对象的生产过程

8.2.2、建造者模式原理类图

image-20210414182531377

8.2.3、类图的模板代码实现

产品角色Product:包含多个组成部件的复杂对象

1
2
3
4
5
6
7
8
9
10
11
12
13
class Product {
private String partA;
private String partB;
private String partC;
// setters方法
public void setPartA(String partA) {
this.partA = partA;
}
// ...
public void show() {
//显示产品的特性
}
}

抽象建造者Builder:包含创建产品各个子部件的抽象方法

1
2
3
4
5
6
7
8
9
10
11
abstract class Builder {
//创建产品对象
protected Product product = new Product();
public abstract void buildPartA();
public abstract void buildPartB();
public abstract void buildPartC();
//返回产品对象
public Product getResult() {
return product;
}
}

具体建造者ConcreteBuilder:实现了抽象建造者接口

1
2
3
4
5
6
7
8
9
10
11
public class ConcreteBuilder extends Builder {
public void buildPartA() {
product.setPartA("建造 PartA");
}
public void buildPartB() {
product.setPartB("建造 PartB");
}
public void buildPartC() {
product.setPartC("建造 PartC");
}
}

指挥者Director:调用建造者中的方法完成复杂对象的创建

1
2
3
4
5
6
7
8
9
10
11
12
13
class Director {
private Builder builder;
public Director(Builder builder) {
this.builder = builder;
}
//产品构建与组装方法
public Product construct() {
builder.buildPartA();
builder.buildPartB();
builder.buildPartC();
return builder.getResult();
}
}

客户类

1
2
3
4
5
6
7
8
public class Client {
public static void main(String[] args) {
Builder builder = new ConcreteBuilder();
Director director = new Director(builder);
Product product = director.construct();
product.show();
}
}

8.3、应用举例

盖房项目需求

  1. 需要建房子:这一过程为打桩、砌墙、封顶
  2. 房子有各种各样的,比如普通房,高楼,别墅,各种房子的过程虽然一样,但是要求不要相同的.
  3. 请编写程序,完成需求.

8.3.1、传统方法

类图:

image-20210414183731177

传统方式的问题分析

  1. 优点是比较好理解,简单易操作。
  2. 设计的程序结构,过于简单,没有设计缓存层对象,程序的扩展和维护不好. 也就是说,这种设计方案,把产品(**即:房子) 和 **创建产品的过程(即:建房子流程) 封装在一起,耦合性增强了
  3. 解决方案:将产品和产品建造过程解耦 => 建造者模式

8.3.2、建造者模式解决盖房子问题

思路分析图解(类图)

image-20210414183956399

8.4、建造者模式在JDK中的应用与源码分析

java.lang.StringBuilder 中的建造者模式

代码说明:

image-20210414185001199

源码中建造者模式角色分析

  • Appendable 接口定义了多个 append 方法(抽象方法), 即 Appendable 为抽象建造者(builder), 定义了抽象方法
  • AbstractStringBuilder 实现了 Appendable 接口方法,这里的 AbstractStringBuilder 已经是建造者(ConcreteBuilder),只是不能实例化
  • StringBuilder 即充当了指挥者角色(Director),同时充当了具体的建造者(ConcreteBuilder)建造方法的实现是由 AbstractStringBuilder 完成 , 而 StringBuilder 继承了 AbstractStringBuilder。直接使用了AbstractStringBuilder实现的方法。

8.5、建造者模式与工厂模式对区别

  • 抽象工厂模式实现对产品家族的创建,一个产品家族是这样的一系列产品:具有不同分类维度的产品组合,采用抽象工厂模式不需要关心构建过程,只关心什么产品由什么工厂生产即可。
  • 建造者模式则是要求按照指定的蓝图建造产品,它的主要目的是通过组装零配件而产生一个新产品

主要区别:

  • 建造者模式更加注重方法的调用顺序工厂模式注重创建对象
  • 创建对象的力度不同建造者模式创建复杂的对象,由各种复杂的部件组成工厂模式创建出来的对象都一样
  • 关注重点不一样工厂模式只需要把对象创建出来就可以了,而建造者模式不仅要创建出对象,还要知道对象由哪些部件组成
  • 建造者模式根据建造过程中的顺序不一样,最终对象部件组成也不一样

建造者模式唯一区别于工厂模式的是针对复杂对象的创建。也就是说,如果创建简单对象,通常都是使用工厂模式进行创建,而如果创建复杂对象,就可以考虑使用建造者模式

当需要创建的产品具备复杂创建过程时,可以抽取出共性创建过程,然后交由具体实现类自定义创建流程,使得同样的创建行为可以生产出不同的产品,分离了创建与表示,使创建产品的灵活性大大增加。

8.6、建造者模式总结

主要优点如下:

  1. 封装性好,构建和表示分离。
  2. 扩展性好,各个具体的建造者相互独立,有利于系统的解耦。
  3. 客户端不必知道产品内部组成的细节,建造者可以对创建过程逐步细化,而不对其它模块产生任何影响,便于控制细节风险

缺点如下:

  1. 产品的组成部分必须相同,这限制了其使用范围。
  2. 如果产品的内部变化复杂,如果产品内部发生变化,则建造者也要同步修改,后期维护成本较大

模式的应用场景:

  • 相同的方法不同的执行顺序产生不同的结果
  • 多个部件或零件,都可以装配到一个对象中,但是产生的结果又不相同
  • 产品类非常复杂,或者产品类中不同的调用顺序产生不同的作用
  • 初始化一个对象特别复杂,参数多,而且很多参数都具有默认值。

8.7、进阶阅读

如果您想了解建造者模式在实际项目中的应用,可猛击阅读以下文章。

8.8、相关的设计模式

  • Template Method 模式

    • 在 Builder 模式中, Director 角色控制 Builder 角色。
    • 在 Template Method 模式中 , 父类控制子类。
  • Composite 模式

    有些情况下 Builder 模式生成的实例构成了 Composite 模式。

  • Abstract Factory 模式

    Builder 模式和 Abstract Factory 模式都用千生成复杂的实例。

  • Facade 模式

    在 Builder 模式中, Director 角色通过组合 Builder 角色中的复杂方法向外部提供可以简单生成 实例的接口 (API) (相当于示例程序中的 construct 方法)。

    Facade 模式中的 Facade 角色则是通过组合内部模块向外部提供可以简单调用的接口 (API)。

8.9、建造者模式的注意事项和细节

  1. 客户端(使用程序)不必知道产品内部组成的细节,将产品本身与产品的创建过程解耦,使得相同的创建过程可以创建不同的产品对象
  2. 每一个具体建造者都相对独立,而与其他的具体建造者无关,因此可以很方便地替换具体建造者或增加新的具体建造者, 用户使用不同的具体建造者即可得到不同的产品对象
  3. 可以更加精细地控制产品的创建过程 。将复杂产品的创建步骤分解在不同的方法中,使得创建过程更加清晰, 也更方便使用程序来控制创建过程
  4. 增加新的具体建造者无须修改原有类库的代码,指挥者类针对抽象建造者类编程,系统扩展方便符合“开闭原则”
  5. 建造者(Builder)模式在应用过程中可以根据需要改变,如果创建的产品种类只有一种,只需要一个具体建造者,这时可以省略抽象建造者,甚至可以省略掉指挥者角色
  6. 建造者模式所创建的产品一般具有较多的共同点,其组成部分相似,如果产品之间的差异性很大,则不适合使用建造者模式,因此其使用范围受到一定的限制。
  7. 如果产品的内部变化复杂,可能会导致需要定义很多具体建造者类来实现这种变化,导致系统变得很庞大,因此在这种情况下,要考虑是否选择建造者模式.

9、适配器模式Adapter(结构型模式)

image-20210415031546174

9.1、基本介绍

  1. 适配器模式(Adapter Pattern)将某个类的接口转换成客户端期望的另一个接口表示,主的目的是兼容性,让原本因接口不匹配不能一起工作的两个类可以协同工作。其别名为包装器(Wrapper)

  2. 适配器模式属于结构型模式

  3. 主要分为三类

    1. 类适配器模式
    2. 对象适配器模式
    3. 接口适配器模式
  4. 工作原理

    适配器模式:将一个类的接口转换成另一种接口.让原本接口不兼容的类可以兼容

    1. 从用户的角度看不到被适配者,是解耦的

    2. 用户调用适配器转化出来的目标接口方法,适配器再调用被适配者的相关接口方法

    3. 用户收到反馈结果,感觉只是和目标接口交互,如图

      image-20210414214311154

9.2、适配器模式原理结构图-uml类图

适配器模式(Adapter)包含以下主要角色。

  1. 目标(Target)接口:当前系统业务所期待的接口,它可以是抽象类或接口
  2. 被适配者(Adaptee\src)类:它是被访问和适配的现存组件库中的组件接口
  3. 适配器(Adapter)类:它是一个转换器,通过继承(类适配器)或引用(对象适配器)适配者的对象,把适配者接口转换成目标接口,让客户按目标接口的格式访问适配者。

9.2.1、类适配器模式

结构图:

image-20210414215313478

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package adapter;
//目标接口
interface Target {
public void request();
}
//适配者接口
class Adaptee {
public void specificRequest() {
System.out.println("适配者中的业务代码被调用!");
}
}
//类适配器类
class ClassAdapter extends Adaptee implements Target {
public void request() {
specificRequest();
}
}
//客户端代码
public class ClassAdapterTest {
public static void main(String[] args) {
System.out.println("类适配器模式测试:");
Target target = new ClassAdapter();
target.request();
}
}

9.2.2、对象适配器模式

结构图:

image-20210414215540735

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package adapter;
//对象适配器类
class ObjectAdapter implements Target {
private Adaptee adaptee;
public ObjectAdapter(Adaptee adaptee) {
this.adaptee=adaptee;
}
public void request() {
adaptee.specificRequest();
}
}
//客户端代码
public class ObjectAdapterTest {
public static void main(String[] args) {
System.out.println("对象适配器模式测试:");
Adaptee adaptee = new Adaptee();
Target target = new ObjectAdapter(adaptee);
target.request();
}
}

9.2.3、接口适配器模式

结构图:

image-20210414215753292

代码:

Interface4(适配者(Adaptee)类):

1
2
3
4
5
6
public interface Interface4 {
public void m1();
public void m2();
public void m3();
public void m4();
}

AbsAdapter(适配器(Adapter)类):

1
2
3
4
5
6
7
8
//在AbsAdapter 我们将 Interface4 的方法进行默认实现
public abstract class AbsAdapter implements Interface4 {
//默认实现
public void m1() {}
public void m2() {}
public void m3() {}
public void m4() {}
}

Client():只需要去覆盖我们 需要使用 接口方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
AbsAdapter absAdapter = new AbsAdapter() {
//只需要去覆盖我们 需要使用 接口方法
@Override
public void m1() {
// TODO Auto-generated method stub
System.out.println("使用了m1的方法");
}
};
absAdapter.m1();
}
}

9.3、三种适配器模式的基本介绍

9.3.1、类适配器模式

实现步骤:Adapter 类,通过继承 src 类,实现 dst 类接口,完成 src->dst 的适配

类适配器模式注意事项和细节:

  1. 由于其继承了 src 类,所以它可以根据需求重写 src 类的方法,使得 Adapter 的灵活性增强了。
  2. Java 是单继承机制,所以类适配器需要继承 src 类这一点算是一个缺点, 因为这要求 dst 必须是接口,有一定局限性。
  3. 由于Adapter继承了src类,所以不可避免的会去重写src的方法。在一定程度上违反了里氏原则合成复用原则
  4. src 类的方法在 Adapter 中都会暴露出来,也增加了使用的成本

9.3.2、对象适配器模式

对象适配器模式介绍

  1. 基本思路和类的适配器模式相同,只是将 Adapter 类作修改,不是继承 src 类,而是持有 src 类的实例(依赖),以解决兼容性的问题**。
  2. 实现步骤:持有 src 类,实现 dst 类接口,完成 src->dst 的适配
  3. 根据“合成复用原则”,在系统中尽量使用关联关系(聚合)来替代继承关系。
  4. 对象适配器模式是适配器模式常用的一种

对象适配器模式注意事项和细节

  1. 对象适配器和类适配器其实算是同一种思想,只不过实现方式不同
  2. 根据合成复用原则,使用聚合替代继承, 所以它解决了类适配器必须继承 src 的局限性问题,也不再要求 dst必须是接口
  3. 使用成本更低,更灵活

9.3.3、接口适配器模式

接口适配器模式介绍

  1. 一些书籍称为:适配器模式(Default Adapter Pattern)**或缺省适配器模式**。
  2. 核心思路:当不需要全部实现接口提供的方法时,可先设计一个抽象类实现接口,并为该接口中每个方法提供一个默认实现(空方法),那么该抽象类的子类可有选择地覆盖父类的某些方法来实现需求
  3. 适用于一个接口不想使用其所有的方法的情况。

接口适配器模式注意事项和细节

  1. JDK8开始,接口就可以默认实现了,所以这个可以不要抽象类,全部弄个默认实现就好。
  2. 然后定义接口的实现类可有选择地覆盖接口的默认方法来实现需求

9.4、应用举例

需求:

以生活中充电器的例子来讲解适配器,充电器本身相当于 Adapter,220V 交流电相当于 src (即被适配者),我们的目 dst(即 目标)是 5V 直流电。

9.4.1、使用类适配器模式实现

思路分析(类图)

image-20210414222333677

代码实现

Voltage220V:被适配者(Adaptee\src)类

1
2
3
4
5
6
7
8
9
//被适配的类
public class Voltage220V {
//输出220V的电压
public int output220V() {
int src = 220;
System.out.println("电压=" + src + "伏");
return src;
}
}

IVoltage5V:目标(Target)接口

1
2
3
4
//适配接口
public interface IVoltage5V {
public int output5V();
}

VoltageAdapter:适配器(Adapter)类

1
2
3
4
5
6
7
8
9
10
11
12
//适配器类
public class VoltageAdapter extends Voltage220V implements IVoltage5V {
@Override
public int output5V() {
// TODO Auto-generated method stub
//获取到220V电压
int srcV = output220V();
int dstV = srcV / 44 ; //转成 5v
return dstV;
}

}

phone\Client:客户端进行使用

phone:

1
2
3
4
5
6
7
8
9
10
public class Phone {
//充电
public void charging(IVoltage5V iVoltage5V) {
if(iVoltage5V.output5V() == 5) {
System.out.println("电压为5V, 可以充电~~");
} else if (iVoltage5V.output5V() > 5) {
System.out.println("电压大于5V, 不能充电~~");
}
}
}

Client:

1
2
3
4
5
6
7
8
public class Client {
public static void main(String[] args) {
System.out.println(" === 类适配器模式 ====");
Phone phone = new Phone();
phone.charging(new VoltageAdapter());
}

}

9.4.2、使用对象适配器模式实现

Voltage220V:被适配者(Adaptee\src)类:同上

IVoltage5V:目标(Target)接口:同上

VoltageAdapter:适配器(Adapter)类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//适配器类
public class VoltageAdapter implements IVoltage5V {
private Voltage220V voltage220V; // 关联关系-聚合
//通过构造器,传入一个 Voltage220V 实例
public VoltageAdapter(Voltage220V voltage220v) {
this.voltage220V = voltage220v;
}
@Override
public int output5V() {
int dst = 0;
if(null != voltage220V) {
int src = voltage220V.output220V();//获取220V 电压
System.out.println("使用对象适配器,进行适配~~");
dst = src / 44;
System.out.println("适配完成,输出的电压为=" + dst);
}
return dst;
}
}

phone\Client:客户端进行使用

phone:同上

Client:

1
2
3
4
5
6
7
public class Client {
public static void main(String[] args) {
System.out.println(" === 对象适配器模式 ====");
Phone phone = new Phone();
phone.charging(new VoltageAdapter(new Voltage220V()));
}
}

9.4.3、接口适配器模式应用实例

  • Android 中的属性动画 ValueAnimator 类可以通过 addListener(AnimatorListener listener)方法添加监听器, 那么常规写法如下:

    image-20210414224410293

  • 有时候我们不想实现 Animator.AnimatorListener 接口的全部方法,我们只想监听 onAnimationStart,我们会如下写

    image-20210414224454278

  • AnimatorListenerAdapter 类,就是一个接口适配器,代码如下图:它空实现了Animator.AnimatorListener 类(src)的所有方法

    image-20210414224618055

  • AnimatorListener 是一个接口

    image-20210414224645907

  • 程序里的匿名内部类就是 Listener 具体实现类

    image-20210414224710829

9.5、适配器模式在Spring MVC的应用与源码分析

  • SpringMvc 中的 HandlerAdapter, 就使用了适配器模式

  • SpringMVC 处理请求的流程回顾

    image-20210414234012692

    image-20210414234049194

  • 使用 HandlerAdapter 的原因分析:

    可以看到处理器的类型不同,有多重实现方式,那么调用方式就不是确定的,如果需要直接调用 Controller 方法,需要调用的时候就得不断是使用 if else 来进行判断是哪一种子类然后执行。那么如果后面要扩展 Controller, 就得修改原来的代码,这样违背了 OCP 原则

  • 代码分析+Debug 源码

    image-20210415002836481

    image-20210415002913253

    image-20210415002940414

    image-20210415003150913

    image-20210415003221357

    image-20210415003242857

image-20210415003342721

相关类图:
image-20210415010612924

动手写 SpringMVC 通过适配器设计模式获取到对应的 Controller 的源码:

相关类图:

image-20210414225130202

实现代码:

HandlerAdapter:一个Adapter接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//定义一个Adapter接口 
public interface HandlerAdapter {
public boolean supports(Object handler);
public void handle(Object handler);
}
// 多种适配器类
class SimpleHandlerAdapter implements HandlerAdapter {
public void handle(Object handler) {
((SimpleController) handler).doSimplerHandler();
}
public boolean supports(Object handler) {
return (handler instanceof SimpleController);
}
}
class HttpHandlerAdapter implements HandlerAdapter {
public void handle(Object handler) {
((HttpController) handler).doHttpHandler();
}
public boolean supports(Object handler) {
return (handler instanceof HttpController);
}
}
class AnnotationHandlerAdapter implements HandlerAdapter {
public void handle(Object handler) {
((AnnotationController) handler).doAnnotationHandler();
}
public boolean supports(Object handler) {
return (handler instanceof AnnotationController);
}
}

Controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//多种Controller实现  
public interface Controller {
}
class HttpController implements Controller {
public void doHttpHandler() {
System.out.println("http...");
}
}
class SimpleController implements Controller {
public void doSimplerHandler() {
System.out.println("simple...");
}
}
class AnnotationController implements Controller {
public void doAnnotationHandler() {
System.out.println("annotation...");
}
}

DispatchServlet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.ArrayList;
import java.util.List;

public class DispatchServlet {
public static List<HandlerAdapter> handlerAdapters = new ArrayList<HandlerAdapter>();
public DispatchServlet() {
handlerAdapters.add(new AnnotationHandlerAdapter());
handlerAdapters.add(new HttpHandlerAdapter());
handlerAdapters.add(new SimpleHandlerAdapter());
}
public void doDispatch() {
// 此处模拟SpringMVC从request取handler的对象,
// 适配器可以获取到希望的Controller
HttpController controller = new HttpController();
// AnnotationController controller = new AnnotationController();
//SimpleController controller = new SimpleController();
// 得到对应适配器
HandlerAdapter adapter = getHandler(controller);
// 通过适配器执行对应的controller对应方法
adapter.handle(controller);
}
public HandlerAdapter getHandler(Controller controller) {
//遍历:根据得到的controller(handler), 返回对应适配器
for (HandlerAdapter adapter : this.handlerAdapters) {
if (adapter.supports(controller)) {
return adapter;
}
}
return null;
}
public static void main(String[] args) {
new DispatchServlet().doDispatch(); // http...
}
}

相关补充:

  • Spring定义了一个适配接口,使得每一种Controller有一种对应的适配器实现类
  • 适配器代替Controller执行相应的方法
  • 扩展Controller时(即添加一个OtherController),只需要增加一个适配器类就完成了SpringMVC的扩展了(满足OCP原则)

9.6、适配器模式总结

主要优点如下:

  • 客户端通过适配器可以透明地调用目标接口
  • 复用了现存的类,程序员不需要修改原有代码而重用现有的适配者类。
  • 目标类和适配者类解耦解决了目标类和适配者类接口不一致的问题。
  • 在很多业务场景中符合开闭原则

其缺点是:

  • 适配器编写过程需要结合业务场景全面考虑,可能会增加系统的复杂性
  • 增加代码阅读难度,降低代码可读性,过多使用适配器会使系统代码变得凌乱。

模式的应用场景:

  • 以前开发的系统存在满足新系统功能需求的类,但其接口同新系统的接口不一致
  • 使用第三方提供的组件,但组件接口定义和自己要求的接口定义不同

9.7、适配器模式的扩展

适配器模式(Adapter)可扩展为双向适配器模式,双向适配器类既可以把适配者接口转换成目标接口,也可以把目标接口转换成适配者接口,其结构图如图所示。

image-20210414225959454

相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package adapter;
//目标接口
interface TwoWayTarget {
public void request();
}
//适配者接口
interface TwoWayAdaptee {
public void specificRequest();
}
//目标实现
class TargetRealize implements TwoWayTarget {
public void request() {
System.out.println("目标代码被调用!");
}
}
//适配者实现
class AdapteeRealize implements TwoWayAdaptee {
public void specificRequest() {
System.out.println("适配者代码被调用!");
}
}
//双向适配器
class TwoWayAdapter implements TwoWayTarget,TwoWayAdaptee {
private TwoWayTarget target;
private TwoWayAdaptee adaptee;
public TwoWayAdapter(TwoWayTarget target) {
this.target=target;
}
public TwoWayAdapter(TwoWayAdaptee adaptee) {
this.adaptee=adaptee;
}
public void request() {
adaptee.specificRequest();
}
public void specificRequest() {
target.request();
}
}
//客户端代码
public class TwoWayAdapterTest {
public static void main(String[] args) {
System.out.println("目标通过双向适配器访问适配者:");
TwoWayAdaptee adaptee = new AdapteeRealize();
TwoWayTarget targe = new TwoWayAdapter(adaptee);
target.request();
System.out.println("-------------------");
System.out.println("适配者通过双向适配器访问目标:");
target = new TargetRealize();
adaptee = new TwoWayAdapter(target);
adaptee.specificRequest();
}
}

9.8、进阶阅读

如果您想了解适配器模式在实际中的应用,可猛击阅读以下文章。

9.9、相关的设计模式

  • Bridge模式

    Adapter模式用千连接接口(API)不同的类,而Bridge模式则用于连接类的功能层次结构与实现层次结构。

  • Decorator 模式

    Adapter 模式用于填补不同接口 (API) 之间的缝隙,而 Decorator 模式则是在不改变接口 (API)的前提下增加功能。

9.10、适配器模式的注意事项和细节

  1. 三种命名方式,是根据 src 是以怎样的形式给到 Adapter(在 Adapter 里的形式)来命名的。
  2. 类适配器:以给到,在 Adapter 里,就是将 src 当做继承
  3. 对象适配器:以对象给到,在 Adapter 里,将 src 作为一个对象,持有接口适配器:以接口给到,在 Adapter 里,将 src 作为一个接口实现
  4. Adapter 模式最大的作用还是将原本不兼容的接口融合在一起工作
  5. 实际开发中,实现起来不拘泥于我们讲解的三种经典形式

10、桥接模式Bridge(结构型模式)

image-20210415032242015

10.1、基本介绍

  1. 桥接模式(Bridge 模式)是指:将实现与抽象放在两个不同的类层次中,使两个层次可以独立改变
  2. 是一种结构型设计模式
  3. Bridge 模式基于类的最小设计原则,通过使用封装聚合继承等行为让不同的类承担不同的职责。它的主要特点是把抽象(Abstraction)与行为实现(Implementation)分离开来,从而可以保持各部分的独立性以及应对他们的功能扩展
  4. 它通过提供抽象化和实现化之间的桥接结构,来实现二者的解耦。
  5. 将抽象与实现分离,使它们可以独立变化。它是用组合关系代替继承关系来实现,从而降低了抽象和实现这两个可变维度的耦合度。

10.2、桥接模式原结构图-uml类图

可以将抽象化部分与实现化部分分开,取消二者的继承关系,改用组合关系。

10.2.1、 模式的结构

桥接(Bridge)模式包含以下主要角色。

  1. 抽象化(Abstraction)角色:定义抽象类,并包含一个对实现化对象的引用
  2. 扩展抽象化(Refined Abstraction)角色:是抽象化角色的子类实现父类中的业务方法,并通过组合关系调用实现化角色中的业务方法
  3. 实现化(Implementor)角色:定义实现化角色的接口,供扩展抽象化角色调用
  4. 具体实现化(Concrete Implementor)角色:给出实现化角色接口的具体实现

相关类图:

image-20210415020129290

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package bridge;

public class BridgeTest {
public static void main(String[] args) {
Implementor imple = new ConcreteImplementorA();
Abstraction abs = new RefinedAbstraction(imple);
abs.Operation();
}
}
//实现化角色
interface Implementor {
public void OperationImpl();
}
//具体实现化角色
class ConcreteImplementorA implements Implementor {
public void OperationImpl() {
System.out.println("具体实现化(Concrete Implementor)角色被访问");
}
}
//抽象化角色
abstract class Abstraction {
protected Implementor imple;

protected Abstraction(Implementor imple) {
this.imple = imple;
}
public abstract void Operation();
}
//扩展抽象化角色
class RefinedAbstraction extends Abstraction {
protected RefinedAbstraction(Implementor imple) {
super(imple);
}
public void Operation() {
System.out.println("扩展抽象化(Refined Abstraction)角色被访问");
imple.OperationImpl();
}
}

10.3、应用举例

手机操作问题:

现在对不同手机类型的不同品牌实现操作编程(比如:开机、关机、上网,打电话等),如图:

image-20210415020831299

10.3.1、使用传统方式

实现类图:

image-20210415021009808

传统方案解决手机操作问题分析

  1. 扩展性问题(类爆炸),如果我们再增加手机的样式(旋转式),就需要增加各个品牌手机的类,同样如果我们增加一个手机品牌,也要在各个手机样式类下增加。
  2. 违反了单一职责原则,当我们增加手机样式时,要同时增加所有品牌的手机,这样增加了代码维护成本.
  3. 解决方案-使用桥接模式

10.3.2、使用桥接模式

对应的类图

image-20210415021509975

对于类图的相关解析:

  1. 在FoldedPhone调用的open()方法其实调用了其父类Phone的open()方法
  2. 然而在Phone当中是通过聚合了Brand接口拿到了open()方法
  3. 而Vivo类才是真正实现Brank接口open()方法的实现类
  4. 所以FoldedPhone调用的open()方法最终是调用了Vivo的open()方法
  5. 而Phone在这其中起到了一个桥接的作用

image-20210415024111311

代码实现:

Brand:实现化(Implementor)

1
2
3
4
5
6
//接口
public interface Brand {
void open();
void close();
void call();
}

Phone:抽象化(Abstraction)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class Phone {
//组合品牌
private Brand brand;
//构造器
public Phone(Brand brand) {
super();
this.brand = brand;
}
protected void open() {
this.brand.open();
}
protected void close() {
brand.close();
}
protected void call() {
brand.call();
}
}

FoldedPhone:扩展抽象化(Refined Abstraction)(UpRightPhone类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//折叠式手机类,继承 抽象类 Phone
public class FoldedPhone extends Phone {
//构造器
public FoldedPhone(Brand brand) {
super(brand);
}
public void open() {
super.open();
System.out.println(" 折叠样式手机 ");
}
public void close() {
super.close();
System.out.println(" 折叠样式手机 ");
}
public void call() {
super.call();
System.out.println(" 折叠样式手机 ");
}
}

Vivo:具体实现化(Concrete Implementor)(XiaoMi类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Vivo implements Brand {
@Override
public void open() {
System.out.println(" Vivo手机开机 ");
}
@Override
public void close() {
System.out.println(" Vivo手机关机 ");
}
@Override
public void call() {
System.out.println(" Vivo手机打电话 ");
}
}

Client:调用者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Client {
public static void main(String[] args) {
//获取折叠式手机 (样式 + 品牌 )
Phone phone1 = new FoldedPhone(new XiaoMi());
phone1.open();
phone1.call();
phone1.close();

System.out.println("=======================");

Phone phone2 = new UpRightPhone(new Vivo());
phone2.open();
phone2.call();
phone2.close();
}

}

10.4、桥接模式在JDBC的应用与源码分析

JDBC的 Driver 接口,如果从桥接模式来看,Driver 就是一个接口,下面可以有 MySQL 的 Driver,Oracle 的Driver,这些就可以当做实现接口类

代码分析+Debug 源码

image-20210415030654222

相关类图:

image-20210415031227696

10.5、桥接模式总结

桥接模式遵循了里氏替换原则依赖倒置原则最终实现了开闭原则,对修改关闭,对扩展开放。这里将桥接模式的优缺点总结如下。

优点:

  • 抽象与实现分离,扩展能力强
  • 符合开闭原则
  • 符合合成复用原则
  • 其实现细节对客户透明

缺点:

由于聚合关系建立在抽象层,要求开发者针对抽象化进行设计与编程,能正确地识别出系统中两个独立变化的维度,这增加了系统的理解与设计难度。

桥接模式的应用场景:

一个类内部具备两种或多种变化维度时,使用桥接模式可以解耦这些变化的维度,使高层代码架构稳定。

桥接模式通常适用于以下场景:

  1. 一个类存在两个独立变化的维度,且这两个维度都需要进行扩展时。
  2. 当一个系统不希望使用继承或因为多层次继承导致系统类的个数急剧增加时。
  3. 当一个系统需要在构件的抽象化角色具体化角色之间增加更多的灵活性时。

桥接模式的一个常见使用场景就是替换继承。我们知道,继承拥有很多优点,比如,抽象、封装、多态等,父类封装共性,子类实现特性。继承可以很好的实现代码复用(封装)的功能,但这也是继承的一大缺点。因为父类拥有的方法,子类也会继承得到,无论子类需不需要,这说明继承具备强侵入性(父类代码侵入子类),同时会导致子类臃肿。因此,在设计模式中,有一个原则为优先使用组合/聚合,而不是继承(合成复用原则)

在实际系统开发时常见的应用场景:

  • JDBC 驱动程序

  • 银行转账系统转账分类:

    网上转账,柜台转账,AMT 转账 (抽象层)

    转账用户类型:普通用户,银卡用户,金卡用户.. (实现层)

  • 消息管理

    消息类型:即时消息,延时消息 (抽象层)

    消息分类:手机短信,邮件消息,QQ 消息… (实现层)

image-20210415025038485

很多时候,我们分不清该使用继承还是组合/聚合或其他方式等,其实可以从现实语义进行思考。因为软件最终还是提供给现实生活中的人使用的,是服务于人类社会的,软件是具备现实场景的。当我们从纯代码角度无法看清问题时,现实角度可能会提供更加开阔的思路。

10.6、桥接模式的扩展

在软件开发中,有时桥接(Bridge)模式可与适配器模式联合使用。当桥接(Bridge)模式的实现化角色的接口与现有类的接口不一致时,可以在二者中间定义一个适配器将二者连接起来,其具体结构图如图所示。

image-20210415025940379

10.7、进阶阅读

如果您想深入了解桥接模式,可猛击阅读以下文章。

10.8、相关设计模式

  • Template Method 模式

    在 Template Method 模式中使用了 "类的实现层次结构"。父类调用抽象方法, 而子类实现抽象方法。

  • Abstract Factory 模式

    为了能够根据需求设计出良好的 ConcreteImplementor 角色, 有时我们会使用Abstract Factory 模式。

  • Adapter 模式

    使用 Bridge 模式可以达到类的功能层次结构与类的实现层次结构分离的目的, 并在此基础上使这些层次结构结合起来。

    而使用Adapter 模式则可以结合那些功能上相似但是接口 (API) 不同的类。

10.9、桥接模式的注意事项和细节

  1. 实现了抽象和实现部分的分离,从而极大的提升了系统的灵活性,让抽象部分和实现部分独立开来,这有助于系统进行分层设计,从而产生更好的结构化系统
  2. 对于系统的高层部分,只需要知道抽象部分和实现部分的接口就可以了,其它的部分由具体业务来完成。
  3. 桥接模式替代多层继承方案,可以减少子类的个数,降低系统的管理和维护成本
  4. 桥接模式的引入增加了系统的理解和设计难度,由于聚合关联关系建立在抽象层,要求开发者**针对抽象进行设计和编程桥接模式要求正确识别出系统中两个独立变化的维度(抽象、和实现)**,因此其使用范围有一定的局限性。

11、装饰者模式Decorator(结构型模式)

image-20210415032321784

11.1、基本介绍

  1. 装饰者模式:在不改变现有对象结构的情况下,动态的将新功能附加到对象上。在对象功能扩展方面,它比继承更有弹性,装饰者模式也**体现了开闭原则(ocp)**。
  2. 它属于对象结构型模式

11.2、装饰者模式原理结构图-uml类图

通常情况下,扩展一个类的功能会使用继承方式来实现。但继承具有静态特征,耦合度高,并且随着扩展功能的增多,子类会很膨胀(类爆炸)。如果使用组合关系来创建一个包装对象(即装饰对象)来包裹真实对象,并在保持真实对象的类结构不变的前提下,为其提供额外的功能,这就是装饰器模式的目标。装饰者模式就像打包一个快递。

下面来分析其基本结构和实现方法。

11.2.1、模式的结构

装饰器模式主要包含以下角色:

  1. 抽象构件(Component)角色:定义一个抽象接口以规范准备接收附加责任的对象。(被装饰者)
  2. 具体构件(ConcreteComponent)角色:实现抽象构件,通过装饰角色为其添加一些职责。
  3. 抽象装饰(Decorator)角色:继承抽象构件,并包含具体构件的实例(组合),可以通过其子类扩展具体构件的功能。(装饰者)
  4. 具体装饰(ConcreteDecorator)角色:实现抽象装饰的相关方法,并给具体构件对象添加附加的责任
  5. (可选)缓冲角色:如果有太多的具体构建角色,可以在具体构件(ConcreteComponent)角色与抽象构件(Component)角色建立一个缓冲角色。抽取具体构件(ConcreteComponent)角色的公共部分,对其进行进一步的抽象。

装饰器模式的结构图:

image-20210415105021790

11.2.2、实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package decorator;

public class DecoratorPattern {
public static void main(String[] args) {
Component p = new ConcreteComponent();
p.operation();
System.out.println("---------------------------------");
Component d = new ConcreteDecorator(p);
d.operation();
}
}
//抽象构件角色
interface Component {
public void operation();
}
//具体构件角色
class ConcreteComponent implements Component {
public ConcreteComponent() {
System.out.println("创建具体构件角色");
}
public void operation() {
System.out.println("调用具体构件角色的方法operation()");
}
}
//抽象装饰角色
class Decorator implements Component {
private Component component;
public Decorator(Component component) {
this.component = component;
}
public void operation() {
component.operation();
}
}
//具体装饰角色
class ConcreteDecorator extends Decorator {
public ConcreteDecorator(Component component) {
super(component);
}
public void operation() {
super.operation();
addedFunction();
}
public void addedFunction() {
System.out.println("为具体构件角色增加额外的功能addedFunction()");
}
}

11.3、应用举例

星巴克咖啡订单项目(咖啡馆):

  1. 咖啡种类/单品咖啡:Espresso(意大利浓咖啡)、ShortBlack、LongBlack(美式咖啡)、Decaf(无因咖啡)
  2. 调料:Milk、Soy(豆浆)、Chocolate
  3. 要求在扩展新的咖啡种类时,具有良好的扩展性、改动方便、维护方便
  4. 使用 OO 的来计算不同种类咖啡的费用: 客户可以点单品咖啡,也可以单品咖啡+调料组合。

11.3.1、使用方案1(较差)解决需求

思路分析(类图):

image-20210415110413076

方案 1-解决星巴克咖啡订单实现与问题分析

  1. Drink 是一个抽象类,表示饮料
  2. des 就是对咖啡的描述, 比如咖啡的名字
  3. cost() 方法就是计算费用,Drink 类中做成一个抽象方法.
  4. Decaf 就是单品咖啡, 继承 Drink, 并实现 cost
  5. Espress && Milk 就是单品咖啡+调料, 这个组合很多
  6. 问题:这样设计,会有很多类,当我们增加一个单品咖啡,或者一个新的调料,类的数量就会倍增,就会出现类爆炸

11.3.2、使用方案2(较好)解决需求

思路分析(类图):

前面分析到方案 1 因为咖啡单品+调料组合会造成类的倍增,因此可以做改进:

  1. 将调料内置到 Drink 类,这样就不会造成类数量过多。从而提高项目的维护性
  2. 说明: milk,soy,chocolate 可以设计为 Boolean,表示是否要添加相应的调料.

image-20210415110911652

方案 2-解决星巴克咖啡订单问题分析

  1. 方案 2 将调料放在了Drink当中,把它作为成员变量。它可以控制类的数量,不至于造成很多的类。
  2. 增加或者删除调料种类时,代码的维护量很大
  3. 考虑到用户可以添加多份调料时,可以将hasMilk返回一个对应int
  4. 考虑使用 装饰者 模式

11.3.3、使用装饰者模式解决需求

说明:

  • Drink 类就是前面说的抽象类,Component
  • ShortBlack 就单品咖啡
  • Decorator 是一个装饰类,含有一个被装饰的对象(Drink obj)
  • Decorator 的cost 方法进行一个费用的叠加计算,递归的计算价格
  • Coffee类就是具体构件(ConcreteComponent)角色与抽象构件(Component)角色之间的缓冲角色,将ShortBlack等等各种咖啡抽象成一个Coffee类

类图:

image-20210415111510913

装饰者模式下的订单:2 份巧克力+一份牛奶的 LongBlack的CoffeeBar(Client)实现思路:

  • Milk包含了LongBlack
  • 一份Chocolate包含了(Milk+LongBlack)
  • 一份Chocolate包含了(Chocolate+Milk+LongBlack)
  • 这样不管是什么形式的单品咖啡+调料组合,通过递归方式可以方便的组合和维护。

image-20210415112049116

实现代码:

Drink:饮料抽象类。抽象构件(Component)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class Drink {
public String des; // 描述
private float price = 0.0f;
public String getDes() {
return des;
}
public void setDes(String des) {
this.des = des;
}
public float getPrice() {
return price;
}
public void setPrice(float price) {
this.price = price;
}
//计算费用的抽象方法
//子类来实现
public abstract float cost();
}

Coffee:咖啡类。(可选)缓冲角色

1
2
3
4
5
6
public class Coffee  extends Drink {
@Override
public float cost() {
return super.getPrice();
}
}

ShortBlack:具体咖啡对象。具体构件(ConcreteComponent)角色(其他具体咖啡类类似)

1
2
3
4
5
6
public class ShortBlack extends Coffee{	
public ShortBlack() {
setDes(" shortblack ");
setPrice(4.0f);
}
}

Decorator:调料装饰者。抽象装饰(Decorator)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Decorator extends Drink {
private Drink obj;
public Decorator(Drink obj) { //组合
this.obj = obj;
}
@Override
public float cost() {
// getPrice 自己价格
return super.getPrice() + obj.cost();
}
@Override
public String getDes() {
// super.des/super.getPrice():输出装饰者的描述信息与价格
// obj.getDes() 输出被装饰者的信息
return super.des + " " + super.getPrice() + " && " + obj.getDes();
}
}

Milk:牛奶。具体装饰(ConcreteDecorator)角色

1
2
3
4
5
6
7
8
public class Milk extends Decorator {
public Milk(Drink obj) {
super(obj);
setDes(" 牛奶 ");
setPrice(2.0f);
}

}

CoffeeBar:星巴克。调用2 份巧克力+一份牛奶的 LongBlack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CoffeeBar {
public static void main(String[] args) {
// 装饰者模式下的订单:2份巧克力+一份牛奶的LongBlack
// 1. 点一份 LongBlack
Drink order = new LongBlack();
System.out.println("费用1=" + order.cost());
System.out.println("描述=" + order.getDes());
// 2. order 加入一份牛奶
order = new Milk(order);
System.out.println("order 加入一份牛奶 费用 =" + order.cost());
System.out.println("order 加入一份牛奶 描述 = " + order.getDes());
// 3. order 加入一份巧克力
order = new Chocolate(order);
System.out.println("order 加入一份牛奶 加入一份巧克力 费用 =" + order.cost());
System.out.println("order 加入一份牛奶 加入一份巧克力 描述 = " + order.getDes());
// 4. order 再加入一份巧克力
order = new Chocolate(order);
System.out.println("order 加入一份牛奶 加入2份巧克力 费用 =" + order.cost());
System.out.println("order 加入一份牛奶 加入2份巧克力 描述 = " + order.getDes());
}
}

11.4、装饰者模式在IO结构的应用与源码

Java 的 IO 结构,FilterInputStream 就是一个装饰者

相关类图:

image-20210415131821549

源码:

image-20210415132235381

image-20210415132326718

image-20210415132344822

对源码的解析:

  1. InputStream 是抽象类, 类似我们前面讲的 Drink
  2. FileInputStream 是 InputStream 子类,类似我们前面的 DeCaf, LongBlack
  3. FilterInputStream 是 InputStream 子类:类似我们前面 的 Decorator 修饰者
  4. DataInputStream 是 FilterInputStream 子类,具体的修饰者,类似前面的 Milk, Soy 等
  5. FilterInputStream 类 有 protected volatile InputStream in; 即含被装饰者
  6. 分析得出在jdk 的io体系中,就是使用装饰者模式

11.5、装饰者模式总结

主要优点有:

  • 装饰器是继承的有力补充,比继承灵活,在不改变原有对象的情况下动态的给一个对象扩展功能,即插即用
  • 通过使用不用装饰类及这些装饰类的排列组合,可以实现不同效果
  • 装饰器模式完全遵守开闭原则
  • 装饰类和被装饰类可以独立发展,不会相互耦合,装饰模式是继承的一个替代模式,装饰模式可以动态扩展一个实现类的功能。

主要缺点:

  • 装饰器模式会增加许多子类,过度使用会增加程序得复杂性

装饰者模式的应用场景:

  • 当需要给一个现有类添加附加职责,而又不能采用生成子类的方法进行扩充时。例如,该类被隐藏或者该类是终极类或者采用继承方式会产生大量的子类
  • 当需要通过对现有的一组基本功能进行排列组合而产生非常多的功能时,采用继承关系很难实现,而采用装饰器模式却很好实现。
  • 当对象的功能要求可以动态地添加,也可以再动态地撤销时。(可插拔)

装饰器模式在 Java 语言中的最著名的应用莫过于 Java I/O 标准库的设计了。例如,InputStream 的子类 FilterInputStream,OutputStream 的子类 FilterOutputStream,Reader 的子类 BufferedReader 以及 FilterReader,还有 Writer 的子类 BufferedWriter、FilterWriter 以及 PrintWriter 等,它们都是抽象装饰类。

11.6、装饰者模式扩展

装饰器模式所包含的 4 个角色不是任何时候都要存在的,在有些应用环境下模式是可以简化的,如以下两种情况。

11.6.1、如果只有一个具体构件而没有抽象构件时,可以让抽象装饰继承具体构件

image-20210415115027987

11.6.2、如果只有一个具体装饰时,可以将抽象装饰和具体装饰合并

image-20210415115057914

11.7、进阶阅读

如果您想深入了解装饰器模式,可猛击阅读以下文章。

11.8、相关设计模式

  • Adapter模式

    Decorator 模式可以在不改变被装饰物的接口 (API) 的前提下, 为被装饰物添加边框(透明性)。

    Adapter 模式用千适配两个不同的接口 (API)。

  • Stragety模式

    Decorator 模式可以像改变被装饰物的边框或是为被装饰物添加多重边框那样, 来增加类的功能。

    Stragety 模式通过整体地替换算法来改变类的功能。

11.9、装饰者模式的注意事项与细节

  • 得益于接口(API)的透明性, Decorator模式中也形成了类似千Composite模式中的递归结构。
  • 也就是说, 装饰边框里面的 ”被装饰物” 实际上又是别的物体的 "装饰边框"。就像是剥洋葱时以为洋葱心要出来了, 结果却发现还是皮。
  • 不过, Decorator模式虽然与Composite模式一样, 都具有递归 结构, 但是它们的使用目的不同。
  • Decorator模式的主要目的是通过添加装饰物来增加对象的功能。

12、组合模式Composite(结构型模式)

image-20210415133116987

12.1、基本介绍

  1. 组合模式(Composite Pattern),又叫部分整体模式,它创建了对象组的树形结构,将对象组合成树状结构以表示“整体-部分”的层次关系。

  2. 组合模式依据树形结构来组合对象,用来表示部分以及整体层次。

  3. 这种类型的设计模式属于结构型模式

  4. 组合模式使得用户对单个对象和组合对象的访问具有一致性,即:组合能让客户以一致的方式处理个别对象以及组合对象

  5. 组合模式创建了一个包含自己对象组的类。该类提供了修改相同对象组的方式。

  6. 组合模式一般用来描述整体与部分的关系,它将对象组织到树形结构中,顶层的节点被称为根节点,根节点下面可以包含树枝节点叶子节点树枝节点下面又可以包含树枝节点和叶子节点,树形结构图如下:

    image-20210415201954362

    由上图可以看出,其实根节点和树枝节点本质上属于同一种数据类型,可以作为容器使用;而叶子节点与树枝节点在语义上不属于用一种类型。但是在组合模式中,会把树枝节点和叶子节点看作属于同一种数据类型(用统一接口定义),让它们具备一致行为

    这样,在组合模式中,整个树形结构中的对象都属于同一种类型,带来的好处就是用户不需要辨别是树枝节点还是叶子节点,可以直接进行操作,给用户的使用带来极大的便利。

12.2、组合模式的原理结构图-uml类图

组合模式主要包含以下角色:

  • 抽象构件(Component)角色:这是组合中对象声明接口,在适当情况下,实现所有类共有的接口默认行为,用于访问和管理Component 子部件, Component 可以是抽象类或者接口
  • 树叶构件(Leaf)角色: 在组合中表示叶子节点,叶子节点没有子节点。用于继承或实现抽象构件。
  • 树枝构件(Composite)角色 / 中间构件:是组合中的分支节点对象,非叶子节点,用于存储子部件。它的主要作用是存储和管理子部件,在Component接口中实现子部件的相关操作,比如增加(add), 删除(remove)。

装饰器模式的结构图:

image-20210415203743668

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class CompositePattern {
public static void main(String[] args) {
Component c0 = new Composite();
Component c1 = new Composite();
Component leaf1 = new Leaf("1");
Component leaf2 = new Leaf("2");
Component leaf3 = new Leaf("3");
c0.add(leaf1);
c0.add(c1);
c1.add(leaf2);
c1.add(leaf3);
c0.operation();
}
}
//抽象构件
interface Component {
public void add(Component c);
public void remove(Component c);
public Component getChild(int i);
public void operation();
}
//树叶构件
class Leaf implements Component {
private String name;
public Leaf(String name) {
this.name = name;
}
public void operation() {
System.out.println("树叶" + name + ":被访问!");
}
}
//树枝构件
class Composite implements Component {
private ArrayList<Component> children = new ArrayList<Component>();
public void add(Component c) {
children.add(c);
}
public void remove(Component c) {
children.remove(c);
}
public Component getChild(int i) {
return children.get(i);
}
public void operation() {
for (Object obj : children) {
((Component) obj).operation();
}
}
}

12.3、应用举例

看一个学校院系展示需求

编写程序展示一个学校院系结构:需求是这样,要在一个页面中展示出学校的院系组成,一个学校有多个学院, 一个学院有多个系。如图:

image-20210415205203158

12.3.1、传统方案解决需求

思路解析(类图)

image-20210415205605144

传统方案解决学校院系展示存在的问题分析

  1. 将学院看做是学校的子类,系是学院的子类,这样实际上是站在组织大小来进行分层次的
  2. 实际上我们的要求是 :在一个页面中展示出学校的院系组成,一个学校有多个学院,一个学院有多个系, 因此这种方案,不能很好实现的管理的操作,比如对学院、系的添加,删除,遍历等
  3. 解决方案:把学校、院、系都看做是组织结构他们之间没有继承的关系,而是一个树形结构,可以更好的实现管理操作。 => 组合模式

12.3.2、组合模式进阶需求

思路分析和图解(类图)

image-20210415215204104

代码实现

OrganizationComponent:组织。抽象构件(Component)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public abstract class OrganizationComponent {
private String name; // 名字
private String des; // 说明
// add():增加
protected void add(OrganizationComponent organizationComponent) {
//默认实现
throw new UnsupportedOperationException();
}
// remove():删除
protected void remove(OrganizationComponent organizationComponent) {
//默认实现
throw new UnsupportedOperationException();
}
//方法print, 做成抽象的, 子类都需要实现
protected abstract void print();
//构造器
public OrganizationComponent(String name, String des) {
super();
this.name = name;
this.des = des;
}
// getters and setters
// ...
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDes() {
return des;
}
public void setDes(String des) {
this.des = des;
}
}

University:大学,组织的一种,管理学院College。树枝构件(Composite)角色 / 中间构件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//University 就是 Composite , 可以管理College
public class University extends OrganizationComponent {
// 组合抽象构件(Component)角色:OrganizationComponent
// List 中 存放的College
List<OrganizationComponent> organizationComponents = new ArrayList<OrganizationComponent>();
// 构造器
public University(String name, String des) {
super(name, des);
}
// 重写add
@Override
protected void add(OrganizationComponent organizationComponent) {
organizationComponents.add(organizationComponent);
}
// 重写remove
@Override
protected void remove(OrganizationComponent organizationComponent) {
organizationComponents.remove(organizationComponent);
}
@Override
public String getName() {
return super.getName();
}
@Override
public String getDes() {
return super.getDes();
}
// print方法,就是输出University 包含的学院
@Override
protected void print() {
System.out.println("--------------" + getName() + "--------------");
//遍历 organizationComponents
for (OrganizationComponent organizationComponent : organizationComponents) {
organizationComponent.print();
}
}
}

College:学院,组织的一种,被University管理,管理各个专业。树枝构件(Composite)角色 / 中间构件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import java.util.ArrayList;
import java.util.List;

public class College extends OrganizationComponent {
// 组合抽象构件(Component)角色:OrganizationComponent
// List 中 存放的Department
List<OrganizationComponent> organizationComponents = new ArrayList<OrganizationComponent>();
// 构造器
public College(String name, String des) {
super(name, des);
}
// 重写add
@Override
protected void add(OrganizationComponent organizationComponent) {
// 将来实际业务中,Colleage 的 add 和 University add 不一定完全一样
organizationComponents.add(organizationComponent);
}
// 重写remove
@Override
protected void remove(OrganizationComponent organizationComponent) {
organizationComponents.remove(organizationComponent);
}
@Override
public String getName() {
return super.getName();
}
@Override
public String getDes() {
return super.getDes();
}
// print方法,就是输出University 包含的学院
@Override
protected void print() {
System.out.println("--------------" + getName() + "--------------");
//遍历 organizationComponents
for (OrganizationComponent organizationComponent : organizationComponents) {
organizationComponent.print();
}
}
}

Department:专业,组织的一种,被学院College管理,本身是叶子构件,没有管理对象。树叶构件(Leaf)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Department extends OrganizationComponent {
// 本身是叶子构件,没有管理对象,没有集合
public Department(String name, String des) {
super(name, des);
}

//add , remove 就不用写了,因为他是叶子节点

@Override
public String getName() {
return super.getName();
}
@Override
public String getDes() {
return super.getDes();
}
@Override
protected void print() {
System.out.println(getName());
}
}

Client:调用方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Client {
public static void main(String[] args) {
//从大到小创建对象 学校
OrganizationComponent university = new University("清华大学", " 中国顶级大学 ");
//创建 学院
OrganizationComponent computerCollege = new College("计算机学院", " 计算机学院 ");
OrganizationComponent infoEngineercollege = new College("信息工程学院", " 信息工程学院 ");
//创建各个学院下面的系(专业)
computerCollege.add(new Department("软件工程", " 软件工程不错 "));
computerCollege.add(new Department("网络工程", " 网络工程不错 "));
computerCollege.add(new Department("计算机科学与技术", " 计算机科学与技术是老牌的专业 "));

infoEngineercollege.add(new Department("通信工程", " 通信工程不好学 "));
infoEngineercollege.add(new Department("信息工程", " 信息工程好学 "));
//将学院加入到 学校
university.add(computerCollege);
university.add(infoEngineercollege);

//university.print();
infoEngineercollege.print();
}
}

12.4、组合模式在JDK的应用与源码

Java 的集合类-HashMap 就使用了组合模式

代码分析+Debug 源码:

image-20210415223718110

相关类图:

image-20210415225016875

说明:

  1. Map 就是一个抽象的构建 (类似我们的Component)

  2. HashMap是一个中间的构建(Composite), 实现/继承了相关方法put, putall等等

  3. Node 是 HashMap的静态内部类,类似Leaf叶子节点, 这里就没有put, putall等方法

    static class Node<K,V> implements Map.Entry<K,V>

Map:

image-20210415224234562

AbstractMap:

image-20210415224422322

HashMap:

image-20210415224607666

image-20210415224705647

Node:HashMap的静态内部类

image-20210415224934928

12.5、组合模式总结

主要优点有:

  1. 组合模式使得客户端代码可以一致地处理单个对象和组合对象,无须关心自己处理的是单个对象,还是组合对象,这简化了客户端代码;
  2. 更容易在组合体内加入新的对象,客户端不会因为加入了新的对象而更改源代码,满足“开闭原则”;

主要缺点是:

  1. 设计较复杂,客户端需要花更多时间理清类之间的层次关系;
  2. 不容易限制容器中的构件;
  3. 不容易用继承的方法来增加构件的新功能;

组合模式的应用场景

  1. 在需要表示一个对象整体与部分的层次结构的场合。
  2. 要求对用户隐藏组合对象与单个对象的不同,用户可以用统一的接口使用组合结构中的所有对象的场合。

应用实例:

  1. 算术表达式包括操作数、操作符和另一个操作数,其中,另一个操作符也可以是操作数、操作符和另一个操作数。
  2. JAVA AWT 和 SWING 中,对于 Button 和 Checkbox 是树叶,Container 是树枝。

12.6、组合模式扩展

如果对前面介绍的组合模式中的树叶节点和树枝节点进行抽象,也就是说树叶节点和树枝节点还有子节点,这时组合模式就扩展成复杂的组合模式了,如 Java AWT/Swing 中的简单组件 JTextComponent 有子类 JTextField、JTextArea,容器组件 Container 也有子类 Window、Panel。复杂的组合模式的结构图如图所示。

image-20210415221758204

12.7、进阶阅读

如果您想深入了解组合模式,可猛击阅读以下文章。

12.8、相关设计模式

  • Command模式

    使用Command模式编写宏命令时使用了Composite模式。

  • Visitor模式

    可以使用Visitor模式访问Composite模式中的递归结构。

  • Decorator 模式

    Composite模式通过Component角色使容器(Composite角色)和内容(Leaf角色)具有一致性

    Decorator模式使装饰框和内容具有一致性。

12.9、组合模式的注意事项与细节

  1. 简化客户端操作。客户端只需要面对一致的对象而不用考虑整体部分或者节点叶子的问题。
  2. 具有较强的扩展性。当我们要更改组合对象时,我们只需要调整内部的层次关系,客户端不用做出任何改动。满足了OCP原则
  3. 方便创建出复杂的层次结构。客户端不用理会组合里面的组成细节,容易添加节点或者叶子从而创建出复杂的树形结构。
  4. 需要遍历组织机构,或者处理的对象具有树形结构时, 非常适合使用组合模式.
  5. 要求较高的抽象性,如果节点和叶子有很多差异性的话,比如很多方法和属性都不一样不适合使用组合模式

13、外观模式Facade(结构型模式)

image-20210415225627971

13.1、基本介绍

  1. 外观模式(Facade),也叫过程模式门面模式:外观模式为子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这一子系统更加容易使用
  2. 外观模式通过定义一个一致的接口,用以屏蔽内部子系统的细节,使得调用端只需跟这个接口发生调用,而无需关心这个子系统的内部细节。这样会大大降低应用程序的复杂度,提高了程序的可维护性。
  3. 外观设计模式属于结构型模式,它向现有的系统添加一个接口,来隐藏系统的复杂性。
  4. 在日常编码工作中,我们都在有意无意的大量使用外观模式。只要是高层模块需要调度多个子系统(2个以上的类对象),我们都会自觉地创建一个新的类封装这些子系统,提供精简的接口,让高层模块可以更加容易地间接调用这些子系统的功能。尤其是现阶段各种第三方SDK开源类库,很大概率都会使用外观模式。

13.2、外观模式的原理结构图-uml类图

外观(Facade)模式的结构比较简单,主要是定义了一个高层接口。它包含了对各个子系统的引用,客户端可以通过它访问各个子系统的功能。现在来分析其基本结构和实现方法。

13.2.1、模式的结构

外观(Facade)模式包含以下主要角色。

  1. 外观(Facade)角色:为多个子系统对外提供一个统一的接口
  2. 子系统(Sub System)角色:实现系统的部分功能,客户可以通过外观角色访问它。
  3. 客户(Client)角色:通过一个外观角色访问各个子系统的功能。

其结构图类图:

image-20210416002002708

13.2.2、模式的实现

外观模式的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package facade;
//客户角色
public class FacadePattern {
public static void main(String[] args) {
Facade f = new Facade();
f.method();
}
}
//外观角色
class Facade {
private SubSystem01 obj1 = new SubSystem01();
private SubSystem02 obj2 = new SubSystem02();
private SubSystem03 obj3 = new SubSystem03();
public void method() {
obj1.method1();
obj2.method2();
obj3.method3();
}
}
//子系统角色
class SubSystem01 {
public void method1() {
System.out.println("子系统01的method1()被调用!");
}
}
//子系统角色
class SubSystem02 {
public void method2() {
System.out.println("子系统02的method2()被调用!");
}
}
//子系统角色
class SubSystem03 {
public void method3() {
System.out.println("子系统03的method3()被调用!");
}
}

13.3、应用举例

image-20210416003604096

13.3.1、使用传统方式解决需求

思路解析(相关类图):

image-20210416003740197

传统方式解决影院管理问题分析

  1. 在 ClientTest 的 main 方法中,创建各个子系统的对象,并直接去调用子系统(对象)相关方法,会造成调用过程混乱,没有清晰的过程

  2. 不利于在 ClientTest 中,去维护对子系统的操作

  3. 解决思路:定义一个高层接口,给子系统中的一组接口提供一个一致的界面(比如在高层接口提供四个方法ready, play, pause, end ),用来访问子系统中的一群接口

  4. 也就是说:就是通过定义一个一致的接口(界面类),用以屏蔽内部子系统的细节(既使子系统之间互相调用),使得调用端只需跟这个接口发生调用,而无需关心这个子系统的内部细节 => 外观模式

    image-20210416004046940

13.3.2、使用外观模式解决需求

传统方式解决影院管理说明:

  1. 外观模式可以理解为转换一群接口,客户只要调用一个接口,而不用调用多个接口才能达到目的。比如:在 pc 上安装软件的时候经常有一键安装选项(省去选择安装目录、安装的组件等等),还有就是手机的重启功能(把关机和启动合为一个操作)。
  2. 外观模式就是解决多个复杂接口带来的使用困难,起到简化用户操作的作用

思路解析(类图):

image-20210416004253007

代码实现:

Screen:显示器。子系统(Sub System)角色,使用单例模式实现子系统角色的创建。(其他子系统角色类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Screen {
//1. 构造器私有化, 外部不能new
private Screen() {}
//2.本类内部创建对象实例
private final static Screen instance = new Screen();
//3. 提供一个公有的静态方法,返回实例对象
public static Screen getInstance() {
return instance;
}
// 显示器相关操作
public void up() {
System.out.println(" Screen up ");
}
public void down() {
System.out.println(" Screen down ");
}
}

HomeTheaterFacade:家庭电影院外观控制器。外观(Facade)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class HomeTheaterFacade {
//定义各个子系统对象
private TheaterLight theaterLight;
private Popcorn popcorn;
private Stereo stereo;
private Projector projector;
private Screen screen;
private DVDPlayer dVDPlayer;
//构造器(单例模式)
public HomeTheaterFacade() {
super();
this.theaterLight = TheaterLight.getInstance();
this.popcorn = Popcorn.getInstance();
this.stereo = Stereo.getInstance();
this.projector = Projector.getInstance();
this.screen = Screen.getInstance();
this.dVDPlayer = DVDPlayer.getInstanc();
}
//操作分成 4 步
// 开始
public void ready() {
popcorn.on();
popcorn.pop();
screen.down();
projector.on();
stereo.on();
dVDPlayer.on();
theaterLight.dim();
}
// 播放
public void play() {
dVDPlayer.play();
}
// 暂停
public void pause() {
dVDPlayer.pause();
}
// 结束
public void end() {
popcorn.off();
theaterLight.bright();
screen.up();
projector.off();
stereo.off();
dVDPlayer.off();
}
}

Client:客户端。客户(Client)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
HomeTheaterFacade homeTheaterFacade = new HomeTheaterFacade();
// 开始
homeTheaterFacade.ready();
// 播放
homeTheaterFacade.play();
// 暂停
homeTheaterFacade.pause();
// 结束
homeTheaterFacade.end();
}
}

13.4、外观模式在Mybatis的应用与源码

MyBatis 中的 Configuration 去创建 MetaObject 对象使用到外观模式

代码分析+Debug 源码+示意图

image-20210416005522209

对源码中使用到的外观模式的角色类图:

image-20210416010838982

Mybatis的Configuration:(Facade外观)

image-20210416005648774

Configuration中组合的三个工厂对象:(子系统Sub System)

image-20210416010052394

Configuration中的newMetaObject()方法

image-20210416010317177

MetaObject:Client借助Mybatis的Configuration生成的对象

image-20210416010527422

image-20210416010710795

13.5、外观模式总结

外观(Facade)模式是“迪米特法则”的典型应用

主要优点:

  1. 降低了子系统与客户端之间的耦合度,使得子系统的变化不会影响调用它的客户类。
  2. 对客户屏蔽了子系统组件,减少了客户处理的对象数目,并使得子系统使用起来更加容易
  3. 降低了大型软件系统中的编译依赖性简化了系统在不同平台之间的移植过程,因为编译一个子系统不会影响其他的子系统,也不会影响外观对象。

主要缺点:

  1. 不能很好地限制客户使用子系统类,很容易带来未知风险。
  2. 增加新的子系统可能需要修改外观类或客户端的源代码违背了“开闭原则”,继承重写都不合适。

外观模式的应用场景:

  1. 分层结构系统构建时,使用外观模式定义子系统中每层的入口点可以简化子系统之间的依赖关系。
  2. 一个复杂系统的子系统很多时,外观模式可以为系统设计一个简单的接口供外界访问。
  3. 客户端与多个子系统之间存在很大的联系时,引入外观模式可将它们分离,从而提高子系统的独立性和可移植性。

外观模式应用实例:

  1. 去医院看病,可能要去挂号、门诊、划价、取药,让患者或患者家属觉得很复杂,如果有提供接待人员,只让接待人员来处理,就很方便
  2. JAVA 的三层开发模式:Controller、Service、Dao
  3. 很多Web程序,内部有多个子系统提供服务,经常使用一个统一的Facade入口,例如一个RestApiController,使得外部用户调用的时候,只关心Facade提供的接口,不用管内部到底是哪个子系统处理的。
  4. 更复杂的Web程序,会有多个Web服务,这个时候,经常会使用一个统一的网关入口来自动转发到不同的Web服务,这种提供统一入口的网关就是Gateway,它本质上也是一个Facade,但可以附加一些用户认证、限流限速的额外服务。

13.6、外观模式扩展

在外观模式中,当增加或移除子系统时需要修改外观类,这违背了“开闭原则”。如果引入抽象外观类,则在一定程度上解决了该问题,其结构图如图所示:

image-20210416002803095

13.7、进阶阅读

如果您想了解外观模式的实际应用,可猛击阅读《使用外观模式整合调用已知API》一节。

13.8、相关设计模式

  • Abstract Factory 模式

    可以将AbstractFactory模式看作生成复杂实例时的Facade模式。 因为它提供了 “要想生成这个实例只需要调用这个方法就OK了" 的简单接口。

  • Singleton 模式

    有时会使用Singleton模式创建Facade角色。

  • Mediator 模式

    在Facade模式中,Facade角色单方面地使用其他角色来提供高层接口(API)。

    而在Mediator模式中,Mediator角色作为Colleague角色间的仲裁者负责调停。 可以说, Facade模式是单向的, 而Mediator角色是双向的。

13.9、外观模式的注意事项与细节

  1. 外观模式对外屏蔽了子系统的细节,因此外观模式降低了客户端对子系统使用的复杂性
  2. 外观模式对客户端与子系统的耦合关系 - 解耦,让子系统内部的模块更易维护和扩展
  3. 通过合理的使用外观模式,可以帮我们更好的划分访问的层次
  4. 当系统需要进行分层设计时,可以考虑使用 Facade 模式
  5. 维护一个遗留的大型系统时,可能这个系统已经变得非常难以维护和扩展,此时可以考虑为新系统开发一个Facade 类,来提供遗留系统的比较清晰简单的接口,让新系统与 Facade 类交互,提高复用性
  6. 不能过多的或者不合理的使用外观模式,使用外观模式好,还是直接调用模块好。要以让系统有层次利于维护为目的。

14、享元模式Flyweight(结构型模式)

image-20210416011234518

14.1、基本介绍

  1. 享元模式(Flyweight Pattern) 也叫 蝇量模式: 运用共享技术有效地支持大量细粒度的对象。它通过共享已经存在的对象来大幅度减少需要创建的对象数量、避免大量相似类的开销,从而提高系统资源的利用率。

  2. 享元模式属于结构型模式,它提供了减少对象数量从而改善应用所需的对象结构的方式

  3. 常用于系统底层开发,解决系统的性能问题。像数据库连接池,里面都是创建好的连接对象,在这些连接对象中有我们需要的则直接拿来用,避免重新创建,如果没有我们需要的,则创建一个。

  4. 享元模式能够解决重复对象的内存浪费的问题,当系统中有大量相似对象,需要缓冲池时。不需总是创建新对象,可以从缓冲池里拿。这样可以降低系统内存,同时提高效率

  5. 享元模式的核心思想:如果一个对象实例一经创建就不可变,那么反复创建相同的实例就没有必要,直接向调用方返回一个共享的实例就行,这样即节省内存,又可以减少创建对象的过程提高运行速度。(注意区别单例模式)

    一言蔽之:通过尽量共享实例来避免new出实例

  6. 享元模式经典的应用场景就是池技术了,String 常量池数据库连接池缓冲池等等都是享元模式的应用,享元模式是池技术的重要实现方式

    image-20210416020802732

14.2、享元模式的原理结构图-uml类图

14.2.1、内部状态与外部状态

享元模式的定义提出了两个要求,细粒度共享对象。因为要求细粒度,所以不可避免地会使对象数量多且性质相近,此时我们就将这些对象的信息分为两个部分:内部状态和外部状态。

  • 内部状态指对象共享出来的信息,存储在享元信息内部,并且不会随环境的改变而改变
  • 外部状态指对象得以依赖的一个标记随环境的改变而改变,不可共享

比如围棋、五子棋、跳棋,它们都有大量的棋子对象,围棋和五子棋只有黑白两色,跳棋颜色多一点,所以棋子颜色就是棋子的内部状态;而各个棋子之间的差别就是位置的不同,当我们落子后,落子颜色是定的,但位置是变化的,所以棋子坐标就是棋子的外部状态

又比如,连接池中的连接对象,保存在连接对象中的用户名密码连接URL等信息,在创建对象的时候就设置好了,不会随环境的改变而改变,这些为内部状态。而当每个连接要被回收利用时,我们需要将它标记为可用状态,这些为外部状态

享元模式的本质是缓存共享对象,降低内存消耗。

举个例子:围棋理论上有 361 个空位可以放棋子,每盘棋都有可能有两三百个棋子对象产生,因为内存空间有限,一台服务器很难支持更多的玩家玩围棋游戏,如果用享元模式来处理棋子,那么棋子对象就可以减少到只有两个实例,这样就很好的解决了对象的开销问题

14.2.2、模式的结构

享元模式的主要角色有如下。

  1. 抽象享元角色(Flyweight)(轻量级):是所有的具体享元类的基类,为具体享元规范需要实现的公共接口非享元的外部状态参数的形式通过方法传入
  2. 具体享元(Concrete Flyweight)角色实现抽象享元角色中所规定的接口
  3. 非享元(Unsharable Flyweight)角色:是不可以共享的外部状态,它以参数的形式注入具体享元的相关方法中。一般不会出现在享元工厂
  4. 享元工厂(Flyweight Factory)角色(轻量级):负责创建和管理享元角色。用于构建一个池容器(集合), 同时提供从池中获取对象方法(池技术)。当客户对象请求一个享元对象时,享元工厂检査系统中是否存在符合要求的享元对象,如果存在则提供给客户;如果不存在的话,则创建一个新的享元对象。

享元模式的结构图,其中:

  • UnsharedConcreteFlyweight 是非享元角色,里面包含了非共享的外部状态信息 info;
  • Flyweight 是抽象享元角色,里面包含了享元方法 operation(UnsharedConcreteFlyweight state),非享元的外部状态以参数的形式通过该方法传入;
  • ConcreteFlyweight 是具体享元角色,包含了关键字 key,它实现了抽象享元接口;
  • FlyweightFactory 是享元工厂角色,它是关键字 key 来管理具体享元;
  • 客户角色通过享元工厂获取具体享元,并访问具体享元的相关方法。

image-20210416021500116

14.2.3、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class FlyweightPattern {
public static void main(String[] args) {
FlyweightFactory factory = new FlyweightFactory();
Flyweight f01 = factory.getFlyweight("a");
Flyweight f02 = factory.getFlyweight("a");
Flyweight f03 = factory.getFlyweight("a");
Flyweight f11 = factory.getFlyweight("b");
Flyweight f12 = factory.getFlyweight("b");
f01.operation(new UnsharedConcreteFlyweight("第1次调用a。"));
f02.operation(new UnsharedConcreteFlyweight("第2次调用a。"));
f03.operation(new UnsharedConcreteFlyweight("第3次调用a。"));
f11.operation(new UnsharedConcreteFlyweight("第1次调用b。"));
f12.operation(new UnsharedConcreteFlyweight("第2次调用b。"));
}
}
//非享元角色
class UnsharedConcreteFlyweight {
private String info;
UnsharedConcreteFlyweight(String info) {
this.info = info;
}
public String getInfo() {
return info;
}
public void setInfo(String info) {
this.info = info;
}
}
//抽象享元角色
interface Flyweight {
public void operation(UnsharedConcreteFlyweight state);
}
//具体享元角色
class ConcreteFlyweight implements Flyweight {
private String key;
ConcreteFlyweight(String key) {
this.key = key;
System.out.println("具体享元" + key + "被创建!");
}
public void operation(UnsharedConcreteFlyweight outState) {
System.out.print("具体享元" + key + "被调用,");
System.out.println("非享元信息是:" + outState.getInfo());
}
}
//享元工厂角色
class FlyweightFactory {
// 使用HashMap作池
private HashMap<String, Flyweight> flyweights = new HashMap<String, Flyweight>();
public Flyweight getFlyweight(String key) {
Flyweight flyweight = (Flyweight) flyweights.get(key);
if (flyweight != null) {
System.out.println("具体享元" + key + "已经存在,被成功获取!");
} else {
flyweight = new ConcreteFlyweight(key);
flyweights.put(key, flyweight);
}
return flyweight;
}
}

14.3、应用举例

展示网站项目需求:

小型的外包项目,给客户 A 做一个产品展示网站,客户 A 的朋友感觉效果不错,也希望做这样的产品展示网站,但是要求都有些不同:

  • 有客户要求以新闻的形式发布
  • 有客户人要求以博客的形式发布
  • 有客户希望以微信公众号的形式发布

14.3.1、使用传统方式解决需求

  1. 直接复制粘贴一份,然后根据客户不同要求,进行定制修改
  2. 给每个网站租用一个空间

思路分析(类图):

image-20210416024816721

传统方案解决网站展现项目问题分析:

  1. 需要的网站结构相似度很高,而且都不是高访问量网站,如果分成多个虚拟空间来处理,相当于一个相同网站的实例对象很多,造成服务器的资源浪费
  2. 解决思路:整合到一个网站中,共享其相关的代码和数据,对于硬盘、内存、CPU、数据库空间等服务器资源都可以达成共享,减少服务器资源
  3. 对于代码来说,由于是一份实例,维护和扩展都更加容易
  4. 上面的解决思路就可以使用享元模式来解决

14.3.2、使用享元模式解决需求

思路分析和图解(类图):

image-20210416025317394

代码实现:

WebSite:网站,当中的use方法传入外部状态User。抽象享元角色(Flyweight)

1
2
3
public abstract class WebSite {
public abstract void use(User user);//抽象方法
}

ConcreteWebSite:具体网站,继承WebSite,包含有内部状态type。具体享元(Concrete Flyweight)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
//具体网站
public class ConcreteWebSite extends WebSite {
//共享的部分,内部状态
private String type = ""; //网站发布的形式(类型)
//构造器
public ConcreteWebSite(String type) {
this.type = type;
}
@Override
public void use(User user) {
System.out.println("网站的发布形式为:" + type + " 在使用中 .. 使用者是" + user.getName());
}
}

User:用户,不同的用户的构建网站的类型不同,属于外部状态,通过方法参数传入。非享元(Unsharable Flyweight)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
public class User {
private String name;
public User(String name) {
super();
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

WebSiteFactory:网站工厂类,根据需要返回一个网站。享元工厂(Flyweight Factory)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.HashMap;

// 网站工厂类,根据需要返回一个网站
public class WebSiteFactory {
//集合, 充当池的作用
private HashMap<String, ConcreteWebSite> pool = new HashMap<>();
//根据网站的类型,返回一个网站, 如果没有就创建一个网站,并放入到池中,并返回
public WebSite getWebSiteCategory(String type) {
if(!pool.containsKey(type)) {
//就创建一个网站,并放入到池中
pool.put(type, new ConcreteWebSite(type));
}
return (WebSite)pool.get(type);
}
//获取网站分类的总数 (池中有多少个网站类型)
public int getWebSiteCount() {
return pool.size();
}
}

Client:客户端调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Client {
public static void main(String[] args) {
// 创建一个工厂类
WebSiteFactory factory = new WebSiteFactory();
// 客户tom要一个以新闻形式发布的网站
WebSite webSite1 = factory.getWebSiteCategory("新闻");
webSite1.use(new User("tom"));
// 客户jack要一个以博客形式发布的网站
WebSite webSite2 = factory.getWebSiteCategory("博客");
webSite2.use(new User("jack"));
// 客户smith要一个以博客形式发布的网站
WebSite webSite3 = factory.getWebSiteCategory("博客");
webSite3.use(new User("smith"));
// 客户king要一个以博客形式发布的网站
WebSite webSite4 = factory.getWebSiteCategory("博客");
webSite4.use(new User("king"));
// 网站的分类共=2
System.out.println("网站的分类共=" + factory.getWebSiteCount());
}
}

14.4、享元模式在JDK-Integer的应用与源码

Integer 中的享元模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class FlyWeight {
public static void main(String[] args) {
Integer x = Integer.valueOf(127); // 得到 x实例,类型 Integer
Integer y = new Integer(127); // 得到 y 实例,类型 Integer
Integer z = Integer.valueOf(127);//..
Integer w = new Integer(127);

System.out.println(x.equals(y)); // 大小,true
System.out.println(x == y ); // false
System.out.println(x == z ); // true
System.out.println(w == x ); // false
System.out.println(w == y ); // false

Integer x1 = Integer.valueOf(200);
Integer x2 = Integer.valueOf(200);
System.out.println("x1==x2" + (x1 == x2)); // false
}
}

代码分析:

  • 如果 Integer.valueOf(x)的参数 x 在 -128 — 127 之间,就是使用享元模式返回,如果不在范围内,则仍然 new 一个Integer对象
  • 在valueOf 方法中,先判断值是否在 IntegerCache 中,如果不在,就创建新的Integer(new);否则,就直接从缓存池返回
  • valueOf 方法,就使用到享元模式
  • 如果使用valueOf 方法得到一个Integer 实例,范围在 -128 - 127 ,执行速度比 new 快

为什么使用Integer.valueOf(x)并且其参数 x 的范围在 -128 — 127 之间就是同一个对象,看源码:

Debug 源码+说明:

Integer:

image-20210416031357474

Integer当中的IntegerCache:

image-20210416031820357

image-20210416032125850

14.5、享元模式总结

主要优点是

  • 相同对象只要保存一份,这降低了系统中对象的数量,从而降低了系统中细粒度对象给内存带来的压力

主要缺点是:

  • 为了使对象可以共享,需要将一些不能共享的状态外部化,这将增加程序的复杂性
  • 享元模式提高了系统的复杂度。需要分离出内部状态和外部状态,而外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。这是我们使用享元模式需要注意的地方。
  • 读取享元模式的外部状态会使得运行时间稍微变长

享元模式的应用场景:

当系统中多处需要同一组信息时,可以把这些信息封装到一个对象中,然后对该对象进行缓存,这样,一个对象就可以提供给多出需要使用的地方,避免大量同一对象的多次创建降低大量内存空间的消耗

享元模式其实是工厂方法模式的一个改进机制享元模式同样要求创建一个或一组对象,并且就是通过工厂方法模式生成对象的,只不过享元模式为工厂方法模式增加了缓存这一功能。

享元模式是通过减少内存中对象的数量来节省内存空间的,所以以下几种情形适合采用享元模式。

  1. 系统中存在大量相同或相似的对象,这些对象耗费大量的内存资源。
  2. 大部分的对象可以按照内部状态进行分组,且可将不同部分外部化,这样每一个组只需保存一个内部状态
  3. 由于享元模式需要额外维护一个保存享元的数据结构(多为HashMap\HashTable),所以应当在有足够多的享元实例时才值得使用享元模式。

14.6、享元模式扩展

在前面介绍的享元模式中,其结构图通常包含可以共享的部分不可以共享的部分。在实际使用过程中,有时候会稍加改变,即存在两种特殊的享元模式:单纯享元模式复合享元模式

14.6.1、单纯享元模式

这种享元模式中的所有的具体享元类都是可以共享的,不存在非共享的具体享元类。如类图所示:

image-20210416024136156

14.6.2、复合享元模式

这种享元模式中的有些享元对象是由一些单纯享元对象组合而成的,它们就是复合享元对象。虽然复合享元对象本身不能共享,但它们可以分解成单纯享元对象再被共享。如类图所示:

image-20210416024246399

14.7、进阶阅读

如果您想深入了解享元模式,可猛击阅读以下文章。

14.8、相关设计模式

  • Proxy 模式

    如果生成实例的处理需要花费较长时间, 那么使用 Flyweight 模式可以提高程序的处理速度。

    而 Proxy 模式则是通过设置代理提高程序的处理速度。

  • Composite 模式

    有时可以使用 Flyweight 模式共享 Composite 模式中的 Leaf 角色。

  • Singleton 模式

    在 FlyweightFactory 角色中有时会使用 Singleton 模式。

    此外如果使用了 Singleton 模式,由于只会生成一个 Singleton 角色,因此所有使用该实例的地方都共享同一个实例。 在 Singleton 角色的实例中只持有内部(固有)信息。

14.9、享元模式的注意事项与细节

  1. 在享元模式这样理解,“享”就表示共享“元”表示对象
  2. 系统中有大量对象,这些对象消耗大量内存,并且对象的状态大部分可以外部化时,我们就可以考虑选用享元模式
  3. 唯一标识码判断,如果在内存中有,则返回这个唯一标识码所标识的对象,用 HashMap/HashTable 存储
  4. 享元模式大大减少了对象的创建降低了程序内存的占用提高效率
  5. 享元模式提高了系统的复杂度。需要分离出内部状态和外部状态,而外部状态具有固有化的性质,不应该随着内部状态的变化而变化,否则会造成系统的混乱。这是我们使用享元模式需要注意的地方。
  6. 使用享元模式时,注意划分内部状态和外部状态,并且需要有一个工厂类加以控制
  7. 在使用享元模式的时候要注意:不要让被共享的实例被垃圾回收机制(GC)回收了
  8. 享元模式经典的应用场景是需要缓冲池的场景,比如 String 常量池、数据库连接池。

14.10、享元模式与单例模式的区别

  1. 单例模式是整个应用系统共用一个实例对象

    享元模式是整个系统共用好几个同类型对象

  2. 连接池本身是单例模式,连接池里的多个连接对象是享元模式

  3. 而且享元模式的共享对象是按需分配的,如果不够还会再创建

    单例模式绝对不会重复创建第二个对象,这是本质不同

  4. 享元模式里的共享对象在使用时一定是线程私有的

    就比如共享单车,虽然是共享的,但在使用时一定是只属于你的

  5. 享元模式的共享对象有借有还的,在宏观上是共享的。

15、代理模式Proxy(结构型模式)

image-20210416084620231

15.1、基本介绍

  1. 代理模式:为一个对象提供一个替身,以控制对这个对象的访问。即通过代理对象访问目标对象。这时,访问对象不适合或者不能直接引用目标对象,代理对象作为访问对象和目标对象之间的中介。

  2. 这样做的好处是:可以在目标对象实现的基础上,增强额外的功能操作,即扩展目标对象的功能

  3. 被代理的对象可以是远程对象创建开销大的对象需要安全控制的对象

  4. 代理模式有不同的形式, 主要有三种:

    • 静态代理模式:
      • 静态代理在使用时,需要定义接口或者父类,被代理对象(即目标对象)与代理对象一起实现相同的接口或者是继承相同父类。
    • 动态代理模式 (JDK 代理、接口代理):
      • 代理对象不需要实现接口,但是目标对象要实现接口,否则不能用动态代理
      • 代理对象的生成,是利用 JDK 的 API (反射)动态的在内存中构建代理对象
      • 动态代理也叫做:JDK 代理接口代理
      • JDK 中生成代理对象的 API:
        • 代理类所在包:java.lang.reflect.Proxy
        • JDK 实现代理只需要使用 newProxyInstance 方法,但是该方法需要接收三个参数,完整的写法是:
        • static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces,InvocationHandler h )
          1. ClassLoader loader : 指定当前目标对象使用的类加载器, 获取加载器的方法固定
          2. Class<?>[] interfaces: 目标对象实现的接口类型,使用泛型方法确认类型
          3. InvocationHandler h : 事情处理,执行目标对象的方法时,会触发事情处理器方法, 会把当前执行的目标对象方法作为参数传入
    • Cglib 代理模式(可以在内存动态的创建对象,而不需要实现接口, 他是属于动态代理的范畴) :
      • 静态代理和 JDK 代理模式都要求目标对象是实现一个接口,但是有时候目标对象只是一个单独的对象并没有实现任何的接口,这个时候可使用目标对象子类来实现代理-这就是 Cglib 代理。
      • Cglib 代理也叫作子类代理**,它是在内存中构建一个子类对象从而实现对目标对象功能扩展**, 有些书也将Cglib 代理归属到动态代理。
      • Cglib 是一个强大的高性能的代码生成包,它可以在运行期扩展 java 类与实现 java 接口。它广泛的被许多 AOP 的框架使用,例如 Spring AOP,实现方法拦截
      • 在 AOP 编程中如何选择代理模式:
        • 目标对象需要实现接口,用 JDK 代理
        • 目标对象不需要实现接口,用 Cglib 代理
      • Cglib 包的底层是通过使用字节码处理框架 ASM 来转换字节码并生成新的类
  5. 代理模式总的类图:

    image-20210416132054743

15.2、代理模式的原理结构图-uml类图

代理模式的主要角色如下:

  1. 抽象主题(Subject)类:通过接口或抽象类声明真实主题和代理对象实现的业务方法。
  2. 真实主题(Real Subject)类:实现了抽象主题中的具体业务,是代理对象所代表的真实对象,是最终要引用的对象。
  3. 代理(Proxy)类:提供了与真实主题相同的接口,其内部含有对真实主题的引用,它可以访问、控制或扩展真实主题的功能。

15.2.1、静态代理模式

相关类图:

image-20210416132423219

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package proxy;

public class ProxyTest {
public static void main(String[] args) {
Proxy proxy = new Proxy();
proxy.Request();
}
}
//抽象主题
interface Subject {
void Request();
}
//真实主题
class RealSubject implements Subject {
public void Request() {
System.out.println("访问真实主题方法...");
}
}
//代理
class Proxy implements Subject {
private RealSubject realSubject;
public void Request() {
if (realSubject == null) {
realSubject = new RealSubject();
}
preRequest();
realSubject.Request();
postRequest();
}
public void preRequest() {
System.out.println("访问真实主题之前的预处理。");
}
public void postRequest() {
System.out.println("访问真实主题之后的后续处理。");
}
}

15.2.2、JDK动态代理模式

相关类图:
image-20210416134249061

执行原理:

preview

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Client {
public static void main(String[] args) {
//创建目标对象
Subject target = new RealSubject();
//给目标对象,创建代理对象, 可以转成 ITeacherDao
Subject proxyInstance = (Subject)new ProxyFactory(target).getProxyInstance();
// proxyInstance=class com.sun.proxy.$Proxy0 内存中动态生成了代理对象
System.out.println("proxyInstance=" + proxyInstance.getClass());
//通过代理对象,调用目标对象的方法
proxyInstance.Request();
}
}
//抽象主题
interface Subject {
void Request();
}
//真实主题
class RealSubject implements Subject {
public void Request() {
System.out.println("访问真实主题方法...");
}
}
// 代理工厂
public class ProxyFactory {
//维护一个目标对象 , Object
private Object target;
//构造器 , 对target 进行初始化
public ProxyFactory(Object target) {
this.target = target;
}
//给目标对象 生成一个代理对象
public Object getProxyInstance() {
//说明
/*
* public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)

//1. ClassLoader loader : 指定当前目标对象使用的类加载器, 获取加载器的方法固定
//2. Class<?>[] interfaces: 目标对象实现的接口类型,使用泛型方法确认类型
//3. InvocationHandler h : 事情处理,执行目标对象的方法时,会触发事情处理器方法, 会把当前执行的目标对象方法作为参数传入
*/
return Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("JDK代理开始~~");
//反射机制调用目标对象的方法
Object returnVal = method.invoke(target, args);
System.out.println("JDK代理提交");
return returnVal;
}
});
}
}

15.2.3、Cglib代理模式

相关类图:

image-20210416135654386

执行原理:
preview

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class Client {
public static void main(String[] args) {
//创建目标对象
RealSubject target = new RealSubject();
//获取到代理对象,并且将目标对象传递给代理对象
RealSubject proxyInstance = (RealSubject)new ProxyFactory(target).getProxyInstance();
//执行代理对象的方法,触发intecept 方法,从而实现 对目标对象的调用
String res = proxyInstance.Request();
System.out.println("res=" + res);
}
}
//真实主题
class RealSubject {
public void Request() {
System.out.println("访问真实主题方法...");
}
}
// 代理工厂
public class ProxyFactory implements MethodInterceptor {
//维护一个目标对象
private Object target;
//构造器,传入一个被代理的对象
public ProxyFactory(Object target) {
this.target = target;
}
//返回一个代理对象: 是 target 对象的代理对象
public Object getProxyInstance() {
//1. 创建一个工具类
Enhancer enhancer = new Enhancer();
//2. 设置父类
enhancer.setSuperclass(target.getClass());
//3. 设置回调函数
enhancer.setCallback(this);
//4. 创建子类对象,即代理对象
return enhancer.create();
}
//重写 intercept 方法,会调用目标对象的方法
@Override
public Object intercept(Object arg0, Method method, Object[] args, MethodProxy arg3) throws Throwable {
System.out.println("Cglib代理模式 ~~ 开始");
Object returnVal = method.invoke(target, args);
System.out.println("Cglib代理模式 ~~ 提交");
return returnVal;
}
}

15.3、应用举例

具体要求:

  1. 定义一个接口:ITeacherDao
  2. 目标对象 TeacherDAO 实现接口 ITeacherDAO
  3. 使用静态代理方式,就需要在代理对象 TeacherDAOProxy 中也实现 ITeacherDAO
  4. 调用的时候通过调用代理对象的方法来调用目标对象.

15.3.1、使用静态代理模式解决需求

思路分析图解(类图):

image-20210416132715029

实现代码:

ITeacherDao:教师操作接口。抽象主题(Subject)类

1
2
3
4
//接口
public interface ITeacherDao {
void teach(); // 授课的方法
}

TeacherDao:教师操作接口实现类。真实主题(Real Subject)类

1
2
3
4
5
6
public class TeacherDao implements ITeacherDao {
@Override
public void teach() {
System.out.println(" 老师授课中 。。。。。");
}
}

TeacherDaoProxy:教师操作代理对象。代理(Proxy)类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//代理对象,静态代理
public class TeacherDaoProxy implements ITeacherDao{
private ITeacherDao target; // 目标对象,通过接口来组合
//构造器
public TeacherDaoProxy(ITeacherDao target) {
this.target = target;
}
@Override
public void teach() {
System.out.println("开始代理 完成某些操作。。。。。 ");//方法
target.teach();
System.out.println("提交。。。。。");//方法
}
}

Client:调用方。

1
2
3
4
5
6
7
8
9
10
11
public class Client {
public static void main(String[] args) {
//创建目标对象(被代理对象)
TeacherDao teacherDao = new TeacherDao();
//创建代理对象, 同时将被代理对象传递给代理对象
TeacherDaoProxy teacherDaoProxy = new TeacherDaoProxy(teacherDao);
//通过代理对象,调用到被代理对象的方法
//即:执行的是代理对象的方法,代理对象再去调用目标对象的方法
teacherDaoProxy.teach();
}
}

15.3.2、使用动态代理模式解决需求

思路分析图解(类图):

image-20210416141108093

代码实现:

ITeacherDao:教师操作接口。抽象主题(Subject)类

1
2
3
4
5
//接口
public interface ITeacherDao {
void teach(); // 授课方法
void sayHello(String name);
}

TeacherDao:教师操作接口实现类。真实主题(Real Subject)类

1
2
3
4
5
6
7
8
9
10
public class TeacherDao implements ITeacherDao {
@Override
public void teach() {
System.out.println(" 老师授课中.... ");
}
@Override
public void sayHello(String name) {
System.out.println("hello " + name);
}
}

ProxyFactory:代理工厂,用来生成代理对象。生成的对象:代理(Proxy)类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
// 代理工厂,用来生成代理对象
public class ProxyFactory {
//维护一个目标对象 , Object
private Object target;
//构造器 , 对target 进行初始化
public ProxyFactory(Object target) {
this.target = target;
}
//给目标对象 生成一个代理对象
public Object getProxyInstance() {
//说明
/*
* public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)

//1. ClassLoader loader : 指定当前目标对象使用的类加载器, 获取加载器的方法固定
//2. Class<?>[] interfaces: 目标对象实现的接口类型,使用泛型方法确认类型
//3. InvocationHandler h : 事情处理,执行目标对象的方法时,会触发事情处理器方法, 会把当前执行的目标对象方法作为参数传入
*/
return Proxy.newProxyInstance(target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("JDK代理开始~~");
//反射机制调用目标对象的方法
Object returnVal = method.invoke(target, args);
System.out.println("JDK代理提交");
return returnVal;
}
});
}
}

Client:调用方。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//创建目标对象
ITeacherDao target = new TeacherDao();
//给目标对象,创建代理对象, 可以转成 ITeacherDao
ITeacherDao proxyInstance = (ITeacherDao)new ProxyFactory(target).getProxyInstance();
// proxyInstance=class com.sun.proxy.$Proxy0 内存中动态生成了代理对象
System.out.println("proxyInstance=" + proxyInstance.getClass());
//通过代理对象,调用目标对象的方法
proxyInstance.teach();
proxyInstance.sayHello(" tom ");
}
}

15.3.3、使用cglib代理模式解决需求

思路分析图解(类图):

image-20210416143133820

代码实现:

TeacherDao:教师操作类。真实主题(Real Subject)类

1
2
3
4
5
6
public class TeacherDao {
public String teach() {
System.out.println(" 老师授课中 , 我是cglib代理,不需要实现接口 ");
return "hello";
}
}

ProxyFactory:代理工厂,实现cglib的MethodInterceptor接口,用来生成代理对象。生成的对象:代理(Proxy)类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.lang.reflect.Method;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
// ProxyFactory:代理工厂,实现cglib的MethodInterceptor接口,用来生成代理对象
public class ProxyFactory implements MethodInterceptor {
//维护一个目标对象
private Object target;
//构造器,传入一个被代理的对象
public ProxyFactory(Object target) {
this.target = target;
}
//返回一个代理对象: 是 target 对象的代理对象
public Object getProxyInstance() {
//1. 创建一个工具类
Enhancer enhancer = new Enhancer();
//2. 设置父类
enhancer.setSuperclass(target.getClass());
//3. 设置回调函数
enhancer.setCallback(this);
//4. 创建子类对象,即代理对象
return enhancer.create();
}
//重写 intercept 方法,会调用目标对象的方法
@Override
public Object intercept(Object arg0, Method method, Object[] args, MethodProxy arg3) throws Throwable {
System.out.println("Cglib代理模式 ~~ 开始");
Object returnVal = method.invoke(target, args);
System.out.println("Cglib代理模式 ~~ 提交");
return returnVal;
}
}

Client:调用方。

1
2
3
4
5
6
7
8
9
10
11
public class Client {
public static void main(String[] args) {
//创建目标对象
TeacherDao target = new TeacherDao();
//获取到代理对象,并且将目标对象传递给代理对象
TeacherDao proxyInstance = (TeacherDao)new ProxyFactory(target).getProxyInstance();
//执行代理对象的方法,触发intecept 方法,从而实现 对目标对象的调用
String res = proxyInstance.teach();
System.out.println("res=" + res);
}
}

15.4、代理模式总结

15.4.1、静态代理优缺点

  • 优点:
    • 在不修改目标对象的功能前提下, 能通过代理对象对目标功能扩展
  • 缺点:
    • 因为代理对象需要与目标对象实现一样的接口,所以会有很多代理类
    • 一旦接口增加方法,目标对象与代理对象都要维护

15.4.2、JDK动态代理优缺点

  • 优点:
    • JDK原声动态代理时java原声支持的、不需要任何外部依赖
  • 缺点:
    • 但是它只能基于接口进行代理(因为它已经继承了proxy了,java不支持多继承)

15.4.3、Cglib动态代理优缺点

优点:

  • CGLIB通过继承的方式进行代理、无论目标对象没有没实现接口都可以代理

缺点:

  • 需要引入 cglib 的 jar 文件

    image-20210416143100693

  • 在内存中动态构建子类,注意代理的类不能为 final,否则报错java.lang.IllegalArgumentException:

  • 目标对象的方法如果为 final/static,那么就不会被拦截,即不会执行目标对象额外的业务方法。(final修饰的方法不能被覆写)

15.4.4、两种动态代理模式的对比(JDK VS CGLIB)

JDK原生动态代理 CGLB动态代理
核心原理 基于”接口实现”方式 基于类集成方式
优点 Java原生支持的,不需要任何依赖 对与代理的目标对象无限制,无需实现接口
不足之处 只能基于接口进行实现 无法处理final方法
实现方式 Java原生支持,不需要任何依赖 需要引用JAR包cglib-nodep-3.2.5.jar和asm.jar

15.4.5、代理模式总结

主要优点有:

  • 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用;
  • 代理对象可以扩展目标对象的功能
  • 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度增加了程序的可扩展性

主要缺点是(有些缺点可通过动态代理解决):

  • 代理模式会造成系统设计中类的数量增加
  • 在客户端和目标对象之间增加一个代理对象,会造成请求处理速度变慢
  • 增加了系统的复杂度

应用场景:

无法或不想直接引用某个对象访问某个对象存在困难时,可以通过代理对象来间接访问。使用代理模式主要有两个目的:一是保护目标对象,二是增强目标对象

  1. 远程代理:

    • 远程代理即Remote Proxy本地的调用者持有的接口实际上是一个代理,这个代理负责把对接口的方法访问转换成远程调用,然后返回结果。
    • 这种方式通常是为了隐藏目标对象存在于不同地址空间的事实,方便客户端访问。例如,用户申请某些网盘空间时,会在用户的文件系统中建立一个虚拟的硬盘,用户访问虚拟硬盘时实际访问的是网盘空间。
    • Java内置的RMI机制就是一个完整的远程代理模式。
  2. 虚拟代理:

    • 虚代理即Virtual Proxy,它让调用者先持有一个代理对象,但真正的对象尚未创建。如果没有必要,这个真正的对象是不会被创建的,直到客户端需要真的必须调用时,才创建真正的对象。
    • 这种方式通常用于要创建的目标对象开销很大时。例如,下载一幅很大的图像需要很长时间,因某种计算比较复杂而短时间无法完成,这时可以先用小比例的虚拟代理替换真实的对象,消除用户对服务器慢的感觉。
    • JDBC的连接池返回的JDBC连接(Connection对象)就可以是一个虚代理,即获取连接时根本没有任何实际的数据库连接,直到第一次执行JDBC查询或更新操作时,才真正创建实际的JDBC连接。
  3. 保护代理:

    • 保护代理即Protection Proxy,它用代理对象控制对原始对象的访问,常用于鉴权。
    • 这种方式通常用于控制不同种类客户对真实对象的访问权限。
  4. 智能引用:

    • 智能引用即Smart Reference,它也是一种代理对象,如果有很多客户端对它进行访问,通过内部的计数器可以在外部调用者都不使用后自动释放它
    • 主要用于调用目标对象时,代理附加一些额外的处理功能。例如,增加计算真实对象的引用次数的功能,这样当该对象没有被引用时,就可以自动释放它。
  5. 延迟加载:

    • 延迟加载即Cache缓存代理。指为了提高系统的性能,延迟对目标的加载。例如,Hibernate 中就存在属性的延迟加载和关联表的延时加载。
    • 当请求图片文件等资源时,先到缓存代理取,如果取到资源则 ok,如果取不到资源,再到公网或者数据库取,然后缓存。
  6. 防火墙(Firewall)代理:内网通过代理穿透防火墙,实现对公网的访问。

  7. 同步化(Synchronization)代理:主要使用在多线程编程中,完成多线程间同步工作

  8. Copy-on-Write 代理:它是虚拟代理的一种,把复制(克隆)操作延迟到只有在客户端真正需要时才执行。一般来说,对象的深克隆是一个开销较大的操作,Copy-on-Write代理可以让这个操作延迟,只有对象被用到的时候才被克隆。

    Immer提供了一种更方便的不可变状态操作方式。详情:Copy-on-write + Proxy = ?

    方便之处主要体现在:

    • 只有一个(核心)API:produce(currentState, producer: (draftState) => void): nextState
    • 不引入额外的数据结构:没有 List、Map、Set 等任何自定义数据结构,因此也不需要特殊的相等性比较方法
    • 数据操作完全基于类型:用纯原生 API 操作数据,符合直觉

应用实际:

  • spring aop
  • 我们实际使用的DataSource,例如HikariCP,都是基于代理模式实现的,原理同上,但增加了更多的如动态伸缩的功能(一个连接空闲一段时间后自动关闭)。
  • 买火车票不一定在火车站买,也可以去代售点。
  • 一张支票或银行存单是账户中资金的代理。支票在市场交易中用来代替现金,并提供对签发人账号上资金的控制。

15.5、代理模式扩展

动态代理的一种实现类图:

image-20210416145841201

15.6、进阶阅读

如果您想深入了解代理模式,可猛击阅读以下文章。

15.7、相关设计模式

  • Adapter 模式:

    Adapter 模式适配了两种具有不同接口 (API) 的对象,以使它们可以一同工作。而在 Proxy 模式中,Proxy 角色与Rea)Subject 角色的接口 (API) 是相同的(透明性)。

  • Decorator 模式:

    Decorator 模式与 Proxy 模式在实现上很相似.不过它们的使用目的不同。

    Decorator 模式的目的在于增加新的功能。而在 Proxy 模式中,与增加新功能相比,它更注重通过设置代理人的方式来减轻本人的工作负担。

15.8、代理模式与其他模式的区别

15.8.1、Proxy模式VSDecorator模式

  • Decorator模式让调用者自己创建核心类,然后组合各种功能
  • Proxy模式决不能让调用者自己创建再组合,否则就失去了代理的功能
  • Proxy模式让调用者认为获取到的是核心类接口,但实际上是代理类。
  • 装饰器模式为了增强功能,而代理模式是为了加以控制。

15.8.2、Proxy模式VSAdapter模式

  • 适配器模式主要改变所考虑对象的接口
  • 代理模式不能改变所代理类的接口

15.9、代理模式的注意事项与细节

  1. 代理模式通过封装一个已有接口,并向调用方返回相同的接口类型,能让调用方在不改变任何代码的前提下增强某些功能(例如,鉴权、延迟加载、连接池复用等)。
  2. 静态代理模式:代理对象与目标对象(被代理对象)都实现同一个接口或者继承同一个抽象类
  3. JDK动态代理模式:目标对象(被代理对象)需要实现接口或继承抽象类,而代理对象不用,它由一个代理工厂来生产
  4. CGLIB动态代模式:目标对象(被代理对象)不需要实现接口或继承抽象类,但是用来生产代理对象的代理工厂需要实现cglib的MethodInterceptor接口。

16、模板方法模式Template Method(行为型模式)

16.1、基本介绍

  1. 模板方法模式(Template Method Pattern),又叫模板模式(Template Pattern)**,在一个抽象类公开定义了执行它的方法的模板。它的子类可以按需要重写方法实现,但调用将以抽象类中定义的方式进行**。
  2. 简单说,模板方法模式定义一个操作中的算法的骨架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构,就可以重定义该算法的某些特定步骤
  3. 模板方法的核心思想是:父类定义骨架子类实现某些细节
  4. 这种类型的设计模式属于行为型模式

16.2、模板方法模式的原理结构图-uml类图

模板方法模式需要注意抽象类与具体子类之间的协作。它用到了虚函数的多态性技术以及“不用调用我,让我来调用你”的反向控制技术

16.2.1、模式的结构

模板方法模式包含以下主要角色:

  • 抽象类/抽象模板(Abstract Class)
    • 抽象模板类,负责给出一个算法的轮廓和骨架。它由一个模板方法和若干个基本方法构成。这些方法的定义如下:
      • 模板方法:定义了算法的骨架,按某种顺序调用其包含的基本方法。
      • 基本方法:是整个算法中的一个步骤,包含以下几种类型:
        • 抽象方法:在抽象类中声明,由具体子类实现。
        • 具体方法:在抽象类中已经实现,在具体子类中可以继承或重写它。
        • 钩子方法:在抽象类中已经实现,我们可以定义一个方法,它默认不做任何事,子类可以视情况要不要覆盖它。包括用于判断的逻辑方法和需要子类重写的空方法两种。
  • 具体子类/具体实现(Concrete Class):具体实现类,实现抽象类中所定义的抽象方法和钩子方法,它们是一个顶级逻辑的一个组成步骤。

相关类图:

image-20210416200400531

16.2.2、模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class TemplateMethodPattern {
public static void main(String[] args) {
AbstractClass tm = new ConcreteClass();
tm.TemplateMethod();
}
}
//抽象类
abstract class AbstractClass {
//模板方法
public void TemplateMethod() {
SpecificMethod();
abstractMethod1();
abstractMethod2();
}
//具体方法
public void SpecificMethod() {
System.out.println("抽象类中的具体方法被调用...");
}
//抽象方法1
public abstract void abstractMethod1();
//抽象方法2
public abstract void abstractMethod2();
}
//具体子类
class ConcreteClass extends AbstractClass {
public void abstractMethod1() {
System.out.println("抽象方法1的实现被调用...");
}
public void abstractMethod2() {
System.out.println("抽象方法2的实现被调用...");
}
}

16.3、应用举例

豆浆制作问题:

编写制作豆浆的程序,说明如下:

  1. 制作豆浆的流程 选材—>添加配料—>浸泡—>放到豆浆机打碎
  2. 通过添加不同的配料,可以制作出不同口味的豆浆
  3. 也可以不添加配料,制作纯豆浆(钩子方法)
  4. 选材、浸泡和放到豆浆机打碎这几个步骤对于制作每种口味的豆浆都是一样的
  5. 请使用模板方法模式完成

思路分析和图解(类图):

image-20210416200834855

代码实现:

SoyaMilk:制作豆浆的抽象类。抽象类/抽象模板(Abstract Class)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//抽象类,表示豆浆
public abstract class SoyaMilk {
//模板方法, make , 模板方法可以做成final , 不让子类去覆盖.
final void make() {
select();
if(customerWantCondiments()) {
addCondiments();
}
soak();
beat();
}
//选材料
void select() {
System.out.println("第一步:选择好的新鲜黄豆 ");
}
//添加不同的配料, 抽象方法, 子类具体实现
abstract void addCondiments();
//浸泡
void soak() {
System.out.println("第三步, 黄豆和配料开始浸泡, 需要3小时 ");
}
//打碎
void beat() {
System.out.println("第四步:黄豆和配料放到豆浆机去打碎 ");
}
//钩子方法,决定是否需要添加配料
boolean customerWantCondiments() {
return true;
}
}

RedBeanSoyaMilk:红豆豆浆。具体子类/具体实现(Concrete Class)(PeanutSoyaMilk等等其他豆浆类似)

1
2
3
4
5
6
public class RedBeanSoyaMilk extends SoyaMilk {
@Override
void addCondiments() {
System.out.println(" 加入上好的红豆 ");
}
}

PureSoyaMilk:纯豆浆。具体子类/具体实现(Concrete Class)(钩子方法)

1
2
3
4
5
6
7
8
9
10
public class PureSoyaMilk extends SoyaMilk{
@Override
void addCondiments() {
//空实现
}
@Override
boolean customerWantCondiments() {
return false;
}
}

Client:调用制作豆浆。客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
//制作红豆豆浆
System.out.println("----制作红豆豆浆----");
SoyaMilk redBeanSoyaMilk = new RedBeanSoyaMilk();
redBeanSoyaMilk.make();

System.out.println("----制作花生豆浆----");
SoyaMilk peanutSoyaMilk = new PeanutSoyaMilk();
peanutSoyaMilk.make();

System.out.println("----制作纯豆浆----");
SoyaMilk pureSoyaMilk = new PureSoyaMilk();
pureSoyaMilk.make();
}
}

16.4、模板方法模式在Spring框架的应用与源码

Spring IOC 容器初始化时运用到的模板方法模式

代码分析+角色分析+说明类图:

16.4.1、说明类图

image-20210416204002753

16.4.2、角色分析

image-20210416204322032

image-20210416204443297

image-20210416204542192

16.4.3、代码分析

ConfigurableApplicationContext接口与refresh()抽象模板方法

image-20210416204750043

image-20210416204918844

AbstractApplicationContext抽象类实现了ConfigurableApplicationContext接口,并对refresh()模板方法进行了重写

image-20210416204959220

image-20210416205223899

refresh()模板方法:

image-20210416205621834

refresh()模板方法当中的obtainFreshBeanFactory()方法

image-20210416205735418

refresh()模板方法当中的钩子方法postProcessBeanFactory()与onRefresh()

image-20210416210548820

image-20210416210318005

image-20210416210706001

GenericApplicationContext类继承了AbstractApplicationContext抽象类,对父类的getBeanFactory()与refreshBeanFactory()抽象方法进行重写

image-20210416211006295

image-20210416211737520

XmlWebApplicationContext类、ClassPathXmlApplicationContext类等等子类继承了各自的父类。最好按照父类定义好的模板去实现对应的需求。

image-20210416212050470

16.5、模板方法模式总结

主要优点:

  1. 封装了不变部分扩展可变部分。它把认为是不变部分的算法封装到父类中实现,而把可变部分算法由子类继承实现,便于子类继续扩展。
  2. 它在父类中提取了公共的部分代码,便于代码复用。
  3. 部分方法是由子类实现的,因此子类可以通过扩展方式增加相应的功能符合开闭原则

主要缺点:

  1. 每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象,间接地增加了系统实现的复杂度。
  2. 父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度
  3. 由于继承关系自身的缺点,如果父类添加新的抽象方法,则所有子类都要改一遍

模式的应用场景:

  1. 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
  2. 多个子类存在公共的行为时,可以将其提取出来并集中到一个公共父类中以避免代码重复。首先,要识别现有代码中的不同之处,并且将不同之处分离为新的操作。最后,用一个调用这些新的操作的模板方法来替换这些不同的代码。
  3. 需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展

应用实例:

  1. 在造房子的时候,地基、走线、水管都一样,只有在建筑的后期才有加壁橱加栅栏等差异
  2. spring 中对 Hibernate 的支持,将一些已经定好的方法封装起来,比如开启事务、获取 Session、关闭 Session 等,程序员不重复写那些已经规范好的代码,直接丢一个实体就可以保存。

16.6、模板方法模式扩展

在模板方法模式中,基本方法包含:抽象方法、具体方法和钩子方法,正确使用“钩子方法”可以使得子类控制父类的行为。如下面例子中,可以通过在具体子类中重写钩子方法 HookMethod1() 和 HookMethod2() 来改变抽象父类中的运行结果,其结构图:

image-20210416203027578

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class HookTemplateMethod {
public static void main(String[] args) {
HookAbstractClass tm = new HookConcreteClass();
tm.TemplateMethod();
}
}
//含钩子方法的抽象类
abstract class HookAbstractClass {
//模板方法
public void TemplateMethod() {
abstractMethod1();
HookMethod1();
if (HookMethod2()) {
SpecificMethod();
}
abstractMethod2();
}
//具体方法
public void SpecificMethod() {
System.out.println("抽象类中的具体方法被调用...");
}
//钩子方法1
public void HookMethod1() {
}
//钩子方法2
public boolean HookMethod2() {
return true;
}
//抽象方法1
public abstract void abstractMethod1();
//抽象方法2
public abstract void abstractMethod2();
}
//含钩子方法的具体子类
class HookConcreteClass extends HookAbstractClass {
public void abstractMethod1() {
System.out.println("抽象方法1的实现被调用...");
}
public void abstractMethod2() {
System.out.println("抽象方法2的实现被调用...");
}
public void HookMethod1() {
System.out.println("钩子方法1被重写...");
}
public boolean HookMethod2() {
return false;
}
}

如果钩子方法 HookMethod1() 和钩子方法 HookMethod2() 的代码改变,则程序的运行结果也会改变。

16.7、进阶阅读

如果您想深入了解模板方法模式,可猛击阅读以下文章。

16.8、相关设计模式

  • Factory Method 模式

    Factory Method模式是将Template Method模式用于生成实例的一个典型例子。

  • Strategy 模式

    在Template Method模式中, 可以使用继承改变程序的行为。 这是因为Template Method模式在父类中定义程序行为的框架.在子类中决定具体的处理。

    与此相对的是Strategy模式 , 它可以使用委托改变程序的行为。 与Template Method模式中改 变部分程序行为不同的是,Strategy模式用于替换整个算法

16.9、模板方法模式的注意事项与细节

  1. 基本思想是:算法只存在于一个地方,也就是在父类中容易修改。需要修改算法时,只要修改父类的模板方法或者已经实现的某些步骤,子类就会继承这些修改。
  2. 实现了最大化代码复用父类的模板方法和已实现的某些步骤会被子类继承而直接使用
  3. 统一了算法,也提供了很大的灵活性。父类的模板方法确保了算法的结构保持不变,同时由子类提供部分步骤的实现。
  4. 该模式的不足之处:每一个不同的实现都需要一个子类实现,导致类的个数增加,使得系统更加庞大
  5. 一般模板方法都加上 final 关键字防止子类重写模板方法
  6. 模板方法模式使用场景:当要完成在某个过程,该过程要执行一系列步骤这一系列的步骤基本相同,但其个别步骤在实现时可能不同,通常考虑用模板方法模式来处理
  7. 模板方法是一种高层定义骨架底层实现细节的设计模式,适用于流程固定但某些步骤不确定或可替换的情况。

17、命令模式Command(行为型模式)

image-20210416212741618

17.1、基本介绍

  1. 命令(Command)模式的定义如下:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。这样两者之间通过命令对象进行沟通,这样方便将命令对象进行储存、传递、调用、增加与管理
  2. 命令模式(Command Pattern):在软件设计中,我们经常需要向某些对象发送请求,但是并不知道请求的接收者是谁,也不知道被请求的操作是哪个,我们只需在程序运行时指定具体的请求接收者即可,此时,可以使用命令模式来进行设计
  3. 命名模式使得请求发送者与请求接收者消除彼此之间的耦合,让对象之间的调用关系更加灵活,实现解耦。
  4. 在命名模式中,会将一个请求封装为一个对象,以便使用不同参数来表示不同的请求(即命名)**,同时命令模式也支持可撤销**的操作。
  5. 通俗易懂的理解:将军发布命令,士兵去执行。其中有几个角色:将军(命令发布者)、士兵(命令的具体执行者)、命令(连接将军和士兵)。Invoker 是调用者(将军),Receiver 是被调用者(士兵),MyCommand 是命令,实现了 Command 接口,持有接收对象

17.2、命令模式的原理结构图-uml类图

17.2.1、模式的结构

命令模式包含以下主要角色。

  1. 抽象命令类(Command)角色:是命令角色,需要执行的所有命令都在这里,拥有执行命令的抽象方法 execute(),可以是接口或抽象类
  2. 具体命令类(Concrete Command)角色:是抽象命令类的具体实现类,它拥有接收者对象,并通过调用接收者的功能来完成命令要执行的操作。将一个接受者对象与一个动作绑定,调用接受者相应的操作,实现 execute
  3. 实现者/接收者(Receiver)角色:执行命令功能的相关操作,是具体命令对象业务的真正实现者。
  4. 调用者/请求者(Invoker)角色:是请求的发送者,它通常拥有很多的命令对象,并通过访问命令对象来执行相关请求,它不直接访问接收者。

其结构图如图:

image-20210417005514313

173.2.2、模式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package command;

public class CommandPattern {
public static void main(String[] args) {
Command cmd = new ConcreteCommand();
Invoker ir = new Invoker(cmd);
System.out.println("客户访问调用者的call()方法...");
ir.call();
}
}
//调用者
class Invoker {
private Command command;
public Invoker(Command command) {
this.command = command;
}
public void setCommand(Command command) {
this.command = command;
}
public void call() {
System.out.println("调用者执行命令command...");
command.execute();
}
}
//抽象命令
interface Command {
public abstract void execute();
public abstract void undo();
}

//具体命令
class ConcreteCommand implements Command {
private Receiver receiver;
ConcreteCommand() {
receiver = new Receiver();
}
public void execute() {
receiver.action();
}
public void undo() {
System.out.println("命令被撤销...");
}
}
//接收者
class Receiver {
public void action() {
System.out.println("接收者的action()方法被调用...");
}
}

17.3、应用举例

17.1 智能生活项目需求

  1. 我们买了一套智能家电,有照明灯、风扇、冰箱、洗衣机,我们只要在手机上安装 app 就可以控制对这些家电工作。
  2. 这些智能家电来自不同的厂家,我们不想针对每一种家电都安装一个 App,分别控制,我们希望只要一个app就可以控制全部智能家电。
  3. 要实现一个 app 控制所有智能家电的需要,则每个智能家电厂家都要提供一个统一的接口给 app 调用,这时 就可以考虑使用命令模式。
  4. 命令模式可将“动作的请求者”从“动作的执行者”对象中解耦出来.
  5. 在我们的例子中,动作的请求者是手机 app,动作的执行者是每个厂商的一个家电产
  6. 编写程序,使用命令模式完成前面的智能家电项目

思路分析和图解

image-20210417010355805

代码实现:

Command:命令接口。抽象命令类(Command)角色

1
2
3
4
5
6
7
//创建命令接口
public interface Command {
//执行动作(操作)
public void execute();
//撤销动作(操作)
public void undo();
}

LightReceiver:电灯接受者。实现者/接收者(Receiver)角色

1
2
3
4
5
6
7
8
public class LightReceiver {
public void on() {
System.out.println(" 电灯打开了.. ");
}
public void off() {
System.out.println(" 电灯关闭了.. ");
}
}

LightOnCommand:打开电灯的操作。具体命令类(Concrete Command)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LightOnCommand implements Command {
//聚合LightReceiver
LightReceiver light;
//构造器
public LightOnCommand(LightReceiver light) {
super();
this.light = light;
}
@Override
public void execute() {
//调用接收者的方法
light.on();
}
@Override
public void undo() {
//调用接收者的方法
light.off();
}
}

LightOffCommand:关闭电灯的操作。具体命令类(Concrete Command)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class LightOffCommand implements Command {
// 聚合LightReceiver
LightReceiver light;
// 构造器
public LightOffCommand(LightReceiver light) {
super();
this.light = light;
}
@Override
public void execute() {
// 调用接收者的方法
light.off();
}
@Override
public void undo() {
// 调用接收者的方法
light.on();
}
}

NoCommand:空命令。具体命令类(Concrete Command)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 没有任何命令,即空执行: 用于初始化每个按钮, 当调用空命令时,对象什么都不做
* 其实,这样是一种设计模式, 可以省掉对空判断
* @author Administrator
*
*/
public class NoCommand implements Command {
@Override
public void execute() {
}
@Override
public void undo() {
}
}

RemoteController:遥控器。调用者/请求者(Invoker)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class RemoteController {
// 开 按钮的命令数组
Command[] onCommands;
Command[] offCommands;
// 执行撤销的命令
Command undoCommand;
// 构造器,完成对按钮初始化
public RemoteController() {
onCommands = new Command[5];
offCommands = new Command[5];
for (int i = 0; i < 5; i++) {
onCommands[i] = new NoCommand();
offCommands[i] = new NoCommand();
}
}
// 给我们的按钮设置你需要的命令
public void setCommand(int no, Command onCommand, Command offCommand) {
onCommands[no] = onCommand;
offCommands[no] = offCommand;
}
// 按下开按钮
public void onButtonWasPushed(int no) { // no 0
// 找到你按下的开的按钮, 并调用对应方法
onCommands[no].execute();
// 记录这次的操作,用于撤销
undoCommand = onCommands[no];

}
// 按下关按钮
public void offButtonWasPushed(int no) { // no 0
// 找到你按下的关的按钮, 并调用对应方法
offCommands[no].execute();
// 记录这次的操作,用于撤销
undoCommand = offCommands[no];
}
// 按下撤销按钮
public void undoButtonWasPushed() {
undoCommand.undo();
}
}

Client:客户端。调用遥控器RemoteController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Client {
public static void main(String[] args) {
//使用命令设计模式,完成通过遥控器,对电灯的操作
//创建电灯的对象(接受者)
LightReceiver lightReceiver = new LightReceiver();
//创建电灯相关的开关命令
LightOnCommand lightOnCommand = new LightOnCommand(lightReceiver);
LightOffCommand lightOffCommand = new LightOffCommand(lightReceiver);
//需要一个遥控器
RemoteController remoteController = new RemoteController();
//给我们的遥控器设置命令, 比如 no = 0 是电灯的开和关的操作
remoteController.setCommand(0, lightOnCommand, lightOffCommand);
System.out.println("--------按下灯的开按钮-----------");
remoteController.onButtonWasPushed(0);
System.out.println("--------按下灯的关按钮-----------");
remoteController.offButtonWasPushed(0);
System.out.println("--------按下撤销按钮-----------");
remoteController.undoButtonWasPushed();

System.out.println("=========使用遥控器操作电视机==========");
TVReceiver tvReceiver = new TVReceiver();
TVOffCommand tvOffCommand = new TVOffCommand(tvReceiver);
TVOnCommand tvOnCommand = new TVOnCommand(tvReceiver);
//给我们的遥控器设置命令, 比如 no = 1 是电视机的开和关的操作
remoteController.setCommand(1, tvOnCommand, tvOffCommand);
System.out.println("--------按下电视机的开按钮-----------");
remoteController.onButtonWasPushed(1);
System.out.println("--------按下电视机的关按钮-----------");
remoteController.offButtonWasPushed(1);
System.out.println("--------按下撤销按钮-----------");
remoteController.undoButtonWasPushed();
}
}

17.4、命令模式在Spring框架的应用与源码

Spring 框架的 JdbcTemplate 就使用到了命令模式

代码分析:

image-20210417014539023

具体代码:

JdbcTemplate类的query方法

image-20210417021850007

在query方法使用递归调用了query方法

image-20210417022006816

query方法:

image-20210417022151453

StatementCallback接口,里面有doInstatement抽象方法

image-20210417022235885

QueryStatementCallback这个静态内部类实现了StatementCallback接口,在里面实现了doInstatement抽象方法

image-20210417022649537

image-20210417022952614

同时,QueryStatementCallback又作为实现者/接收者(Receiver) 角色执行execute方法

image-20210417023039518

StatementCallback接口的其他实现类:ExecuteStatementCallback

image-20210417023427416

ExecuteStatementCallback的excute方法:最后调用了JdbcTemplate的excute方法

image-20210417023156907

JdbcTemplate的excute方法:

image-20210417023635405

image-20210417023940383

17.5、命令模式总结

主要优点:

  1. 通过引入中间件(抽象接口)降低系统的耦合度
  2. 扩展性良好增加或删除命令非常方便。采用命令模式增加与删除命令不会影响其他类,且满足“开闭原则”
  3. 可以实现宏命令。命令模式可以与组合模式结合,将多个命令装配成一个组合命令,即宏命令
  4. 方便实现 Undo 和 Redo 操作。命令模式可以与后面介绍的备忘录模式结合,实现命令的撤销与恢复
  5. 可以在现有命令的基础上,增加额外功能。比如日志记录,结合装饰器模式会更加灵活

缺点是:

  1. 可能产生大量具体的命令类。因为每一个具体操作都需要设计一个具体命令类,这会增加系统的复杂性。
  2. 命令模式的结果其实就是接收方的执行结果,但是为了以命令的形式进行架构、解耦请求与实现,引入了额外类型结构(引入了请求方与抽象命令接口),增加了理解上的困难。不过这也是设计模式的通病,抽象必然会额外增加类的数量,代码抽离肯定比代码聚合更加难理解。

命令模式的应用场景:

当系统的某项操作具备命令语义,且命令实现不稳定(变化)时,可以通过命令模式解耦请求与实现。使用抽象命令接口使请求方的代码架构稳定,封装接收方具体命令的实现细节。接收方与抽象命令呈现弱耦合(内部方法无需一致),具备良好的扩展性。

命令模式通常适用于以下场景:

  1. 请求调用者需要与请求接收者解耦时,命令模式可以使调用者和接收者不直接交互。
  2. 系统随机请求命令或经常增加、删除命令时,命令模式可以方便地实现这些功能。
  3. 当系统需要执行一组操作时,命令模式可以定义宏命令来实现该功能。
  4. 当系统需要支持命令的撤销(Undo)操作和恢复(Redo)操作时,可以将命令对象存储起来,采用备忘录模式来实现。
  5. 界面的一个按钮都是一条命令、模拟 CMD(DOS 命令)订单的撤销/恢复、触发- 反馈机制

17.6、命令模式扩展

在软件开发中,有时将命令模式与前面学的组合模式联合使用,这就构成了宏命令模式,也叫组合命令模式宏命令包含了一组命令,它充当了具体命令与调用者的双重角色,执行它时将递归调用它所包含的所有命令,其具体结构图如图:

image-20210417013804833

实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package command;
import java.util.ArrayList;

public class CompositeCommandPattern {
public static void main(String[] args) {
AbstractCommand cmd1 = new ConcreteCommand1();
AbstractCommand cmd2 = new ConcreteCommand2();
CompositeInvoker ir = new CompositeInvoker();
ir.add(cmd1);
ir.add(cmd2);
System.out.println("客户访问调用者的execute()方法...");
ir.execute();
}
}
//抽象命令
interface AbstractCommand {
public abstract void execute();
}
//树叶构件: 具体命令1
class ConcreteCommand1 implements AbstractCommand {
private CompositeReceiver receiver;
ConcreteCommand1() {
receiver = new CompositeReceiver();
}
public void execute() {
receiver.action1();
}
}
//树叶构件: 具体命令2
class ConcreteCommand2 implements AbstractCommand {
private CompositeReceiver receiver;
ConcreteCommand2() {
receiver = new CompositeReceiver();
}
public void execute() {
receiver.action2();
}
}
//树枝构件: 调用者
class CompositeInvoker implements AbstractCommand {
private ArrayList<AbstractCommand> children = new ArrayList<AbstractCommand>();
public void add(AbstractCommand c) {
children.add(c);
}
public void remove(AbstractCommand c) {
children.remove(c);
}
public AbstractCommand getChild(int i) {
return children.get(i);
}
public void execute() {
for (Object obj : children) {
((AbstractCommand) obj).execute();
}
}
}
//接收者
class CompositeReceiver {
public void action1() {
System.out.println("接收者的action1()方法被调用...");
}
public void action2() {
System.out.println("接收者的action2()方法被调用...");
}
}

17.7、进阶阅读

如果您想深入了解命令模式,可猛击阅读以下文章。

17.8、相关设计模式

  • Composite 模式

    有时会使用Composite模式实现宏命令(macrocommand)。

  • Memento 模式

    有时会使用Memento模式来保存Command角色的历史记录。

  • Protype 模式

    有时会使用Protype模式复制发生的事件(生成的命令)。

17.9、命令模式的注意事项与细节

  1. 将发起请求的对象与执行请求的对象解耦。发起请求的对象是调用者,调用者只要调用命令对象的 execute()方法就可以让接收者工作,而不必知道具体的接收者对象是谁、是如何实现的,命令对象会负责让接收者执行请求的动作,也就是说:”请求发起者”和“请求执行者”之间的解耦是通过命令对象实现的,命令对象起到了纽带桥梁的作用
  2. 容易设计一个命令队列。只要把命令对象放到列队,就可以多线程的执行命令
  3. 容易实现对请求的撤销和重做
  4. 命令模式不足:可能导致某些系统有过多的具体命令类,增加了系统的复杂度,这点在在使用的时候要注意
  5. 空命令也是一种设计模式,它为我们省去了判空的操作。在上面的实例中,如果没有用空命令,我们每按下一个按键都要判空,这给我们编码带来一定的麻烦。
  6. 命令模式经典的应用场景:界面的一个按钮都是一条命令模拟 CMD(DOS 命令)订单的撤销/恢复触发- 反馈机制

18、访问者模式Visitor(行为型模式)

image-20210417141823535

18.1、基本介绍

  1. 访问者模式(Visitor Pattern),封装一些作用于某种数据结构的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。为数据结构中的每个元素提供多种访问方式。它将对数据的操作与数据结构进行分离,是行为类模式中最复杂的一种模式。
  2. 主要将数据结构与数据操作分离解决数据结构和操作耦合性问题
  3. 访问者模式的基本工作原理是:在被访问的类里面加一个对外提供接待访问者的接口
  4. 访问者模式主要应用场景是:需要对一个对象结构中的对象进行很多不同操作(这些操作彼此没有关联)**,同时需要避免让这些操作”污染”这些对象的类**,可以选用访问者模式解决

18.2、访问者模式的原理结构图-uml类图

访问者(Visitor)模式实现的关键是如何将作用于元素的操作分离出来封装成独立的类

18.2.1、模式的结构

访问者模式包含以下主要角色。

  1. 抽象访问者(Visitor)角色:定义一个访问具体元素的接口,为每个具体元素类对应一个访问操作 visit() ,该操作中的**参数类型标识了被访问的具体元素**。即:为该对象结构中的 ConcreteElement 的每一个类声明一个 visit 操作。
  2. 具体访问者(ConcreteVisitor)角色:实现抽象访问者角色中声明的各个访问操作,确定访问者访问一个元素时该做什么。
  3. 抽象元素(Element)角色:声明一个包含接受操作 accept() 的接口被接受的访问者对象作为 accept() 方法的参数。即:定义一个 accept 方法,接收一个访问者对象。(与抽象访问者(Visitor)角色实现互相关联(但相关联的抽象元素的具体实现类))
  4. 具体元素(ConcreteElement)角色:实现抽象元素角色提供的 accept() 操作,其方法体通常都是 visitor.visit(this)双分派),另外具体元素中可能还包含本身业务逻辑的相关操作。
  5. 对象结构(Object Structure)角色:是一个包含元素角色的容器,提供让访问者对象遍历容器中的所有元素的方法,通常由 ListSetMap 等聚合类实现。

其结构图类图:

img

18.2.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package net.biancheng.c.visitor;
import java.util.*;

public class VisitorPattern {
public static void main(String[] args) {
ObjectStructure os = new ObjectStructure();
os.add(new ConcreteElementA());
os.add(new ConcreteElementB());
Visitor visitor = new ConcreteVisitorA();
os.accept(visitor);
System.out.println("------------------------");
visitor = new ConcreteVisitorB();
os.accept(visitor);
}
}
//抽象访问者
interface Visitor {
// 参数是具体的元素类
void visit(ConcreteElementA element);
void visit(ConcreteElementB element);
}
//具体访问者A类
class ConcreteVisitorA implements Visitor {
public void visit(ConcreteElementA element) {
System.out.println("具体访问者A访问-->" + element.operationA());
}
public void visit(ConcreteElementB element) {
System.out.println("具体访问者A访问-->" + element.operationB());
}
}
//具体访问者B类
class ConcreteVisitorB implements Visitor {
public void visit(ConcreteElementA element) {
System.out.println("具体访问者B访问-->" + element.operationA());
}
public void visit(ConcreteElementB element) {
System.out.println("具体访问者B访问-->" + element.operationB());
}
}
//抽象元素类
interface Element {
// 参数是Visitor访问类
void accept(Visitor visitor);
}
//具体元素A类
class ConcreteElementA implements Element {
public void accept(Visitor visitor) {
// 通过visit(this)实现双分派
visitor.visit(this);
}
public String operationA() {
return "具体元素A的操作。";
}
}
//具体元素B类
class ConcreteElementB implements Element {
public void accept(Visitor visitor) {
visitor.visit(this);
}
public String operationB() {
return "具体元素B的操作。";
}
}
//对象结构角色
class ObjectStructure {
private List<Element> list = new ArrayList<Element>();
public void accept(Visitor visitor) {
Iterator<Element> i = list.iterator();
while (i.hasNext()) {
((Element) i.next()).accept(visitor);
}
}
public void add(Element element) {
list.add(element);
}
public void remove(Element element) {
list.remove(element);
}
}

18.3、应用举例

测评系统的需求

将观众分为男人和女人,对歌手进行测评,当看完某个歌手表演后,得到他们对该歌手不同的评价(评价有不同的种类,比如 成功、失败等,之后会增加一种状态“待定”以测试程序的扩展性)

18.3.1、使用传统方式解决需求

思路分析:

image-20210417144957917

传统方式的问题分析:

  1. 如果系统比较小,还是 ok 的,但是考虑系统增加越来越多新的功能时,对代码改动较大,违反了 ocp 原则, 不利于维护
  2. 扩展性不好,比如增加了新的人员类型,或者管理方法,都不好做
  3. 引出我们会使用新的设计模式 – 访问者模式

18.3.2、使用访问者模式解决需求

思路分析(类图):

image-20210417145718031

代码实现:

Action:行为抽象类,在里面的方法将具体元素作为参数传入抽象访问者(Visitor)角色

1
2
3
4
5
6
public abstract class Action {
//得到男性 的测评
public abstract void getManResult(Man man);
//得到女的 测评
public abstract void getWomanResult(Woman woman);
}

Success:成功的行为。具体访问者(ConcreteVisitor)角色(Fail失败与Wait待定等等行为类似)

1
2
3
4
5
6
7
8
9
10
public class Success extends Action {
@Override
public void getManResult(Man man) {
System.out.println(" 男人给的评价该歌手很成功 !");
}
@Override
public void getWomanResult(Woman woman) {
System.out.println(" 女人给的评价该歌手很成功 !");
}
}

Person:人类,将访问者Visitor作为参数传入accept()方法抽象元素(Element)角色

1
2
3
4
public abstract class Person {
//提供一个方法,让访问者可以访问
public abstract void accept(Action action);
}

Man:男人,重写父类的accept方法,并在accept方法里调用访问者的方法并将this作为参数传入,以此实现双分派。具体元素(ConcreteElement)角色(Woman女人类类似)

1
2
3
4
5
6
7
8
9
10
//说明
//1. 这里我们使用到了双分派, 即首先在客户端程序中,将具体状态作为参数传递Woman中(第一次分派)
//2. 然后Man类调用作为参数的 "具体方法" 中方法getWomanResult, 同时将自己(this)作为参数
// 传入,完成第二次的分派
public class Man extends Person {
@Override
public void accept(Action action) {
action.getManResult(this);
}
}

ObjectStructure:对象结构(Object Structure)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.LinkedList;
import java.util.List;

//数据结构,管理很多人(Man , Woman)
public class ObjectStructure {
//维护了一个集合
private List<Person> persons = new LinkedList<>();
//增加到list
public void attach(Person p) {
persons.add(p);
}
//移除
public void detach(Person p) {
persons.remove(p);
}
//显示测评情况
public void display(Action action) {
for(Person p: persons) {
p.accept(action);
}
}
}

Client:客户端,用来进行调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//创建ObjectStructure
ObjectStructure objectStructure = new ObjectStructure();
objectStructure.attach(new Man());
objectStructure.attach(new Woman());
// 成功
Success success = new Success();
objectStructure.display(success);
// 失败
System.out.println("===============");
Fail fail = new Fail();
objectStructure.display(fail);
// 待定
System.out.println("=======给的是待定的测评========");
Wait wait = new Wait();
objectStructure.display(wait);
}
}

18.4、双分派

整理一下 Visitor 模式中方法的调用关系:

  • accept(接受)方法的调用方式如下:

    element.accept(visitor);

  • visit(访问)方法的调用方式如下:

  • visitor.visit(element);

对比一下这两个方法会发现, 它们是相反的关系。 element 接受 visitor, 而 visitor 又访问 element

在 Visitor 模式中, ConcreteElement 和 ConcreteVisitor 这两个角色共同决定了实际进行的处理。这种消息分发的方式一般被称为双重分发 (double dispatch)。

访问者模式为了实现所谓的“双重分派”,设计了一个回调再回调的机制。因为Java只支持基于多态的单分派模式,这里强行模拟出“双重分派”反而加大了代码的复杂性。

所谓双分派是指不管类怎么变化,我们都能找到期望的方法运行。双分派意味着得到执行的操作取决于请求的种类和两个接收者的类型

上述实例为例,假设我们要添加一个 Wait 的状态类,考察 Man 类和 Woman 类的反应,由于使用了双分派,只需增加一个 Action 子类即可在客户端调用即可,不需要改动任何其他类的代码。

18.5、访问者模式总结

主要优点如下:

  1. 扩展性好。能够在不修改对象结构中的元素的情况下,为对象结构中的元素添加新的功能。
  2. 复用性好。可以通过访问者来定义整个对象结构通用的功能,从而提高系统的复用程度。
  3. 灵活性好。访问者模式将数据结构与作用于结构上的操作解耦,使得操作集合可相对自由地演化而不影响系统的数据结构。
  4. 符合单一职责原则。访问者模式把相关的行为封装在一起,构成一个访问者,使每一个访问者的功能都比较单一。可以做报表、UI、拦截器与过滤器,适用于数据结构相对稳定的系统

主要缺点如下:

  1. 增加新的元素类很困难。在访问者模式中,每增加一个新的元素类,都要在每一个具体访问者类中增加相应的具体操作,这违背了“开闭原则”
  2. 破坏封装。访问者模式中具体元素对访问者公布细节,这破坏了对象的封装性。
  3. 违反了依赖倒置原则。访问者模式依赖了具体类,而没有依赖抽象类
  4. 具体元素对访问者公布细节,违反了迪米特原则

总结一下就是:易于增加的ConcreteVisitor角色,难以增加的ConcreteElement角色

模式的应用场景:

当系统中存在类型数量稳定(固定)的一类数据结构时,可以使用访问者模式方便地实现对该类型所有数据结构的不同操作,而又不会对数据产生任何副作用(脏数据)。

简而言之,就是当对集合中的不同类型数据(类型数量稳定)进行多种操作时,使用访问者模式。

通常在以下情况可以考虑使用访问者(Visitor)模式:

  1. 对象结构相对稳定,但其操作算法经常变化的程序。
  2. 对象结构中的对象需要提供多种不同且不相关的操作,而且要避免让这些操作的变化影响对象的结构。
  3. 对象结构包含很多类型的对象,希望对这些对象实施一些依赖于其具体类型的操作。

18.6、访问者模式扩展

访问者(Visitor)模式是使用频率较高的一种设计模式,它常常同以下两种设计模式联用

  • 与“迭代器模式”联用。因为访问者模式中的“对象结构”是一个包含元素角色的容器,当访问者遍历容器中的所有元素时,常常要用迭代器。如应用举例中的对象结构是用 List 实现的,它通过 List 对象的 Iterator() 方法获取迭代器。如果对象结构中的聚合类没有提供迭代器,也可以用迭代器模式自定义一个。
  • 访问者(Visitor)模式同“组合模式”联用。因为访问者(Visitor)模式中的“元素对象可能是叶子对象或者是容器对象,如果元素对象包含容器对象,就必须用到组合模式,其结构图如图:

image-20210417153952680

18.7、进阶阅读

如果您想深入了解访问者模式,可猛击阅读以下文章。

18.8、相关设计模式

  • Iterator模式

    Iterator模式和Visitor模式都是在某种数据结构上进行处理。

    Iterator模式用于逐个遍历保存在数据结构中的元素。

    Visitor模式用于对保存在数据结构中的元素进行某种特定的处理。

  • Composite模式

    有时访问者所访问的数据结构会使用Composite模式。

  • Interpreter模式

    在Interpreter模式中, 有时会使用Visitor模式。 例如, 在生成了语法树后, 可能会使用Visitor 模式访问语法树的各个节点进行处理。

19、迭代器模式Iterator(行为型模式)

image-20210417155331998

19.1、基本介绍

  1. 迭代器模式(Iterator Pattern)是常用的设计模式,属于行为型模式
  2. 如果我们的集合元素是用不同的方式实现的,有数组,还有 java 的集合类,或者还有其他方式,当客户端要遍历这些集合元素的时候就要使用多种遍历方式,而且还会暴露元素的内部结构,可以考虑使用迭代器模式解决。
  3. 迭代器模式,提供一种遍历集合元素的统一接口,用一致的方法遍历集合元素,不需要知道集合对象的底层表示,即:不暴露其内部的结构
  4. 迭代器模式在客户访问类与聚合类之间插入一个迭代器,这分离了聚合对象与其遍历行为,对客户也隐藏了其内部细节,且满足“单一职责原则”和“开闭原则”

19.2、迭代器模式的原理结构图-uml类图

迭代器模式是通过将聚合对象的遍历行为分离出来,抽象成迭代器类来实现的,其目的是在不暴露聚合对象的内部结构的情况下,让外部代码透明地访问聚合的内部数据。现在我们来分析其基本结构与实现方法。

19.2.1、 模式的结构

迭代器模式主要包含以下角色:

  1. 抽象聚合(Aggregate)角色:一个统一的聚合接口, 将客户端和具体聚合解耦。定义存储、添加、删除聚合对象以及创建迭代器对象的接口。
  2. 具体聚合(ConcreteAggregate)角色:实现抽象聚合类,并提供一个方法,返回一个具体迭代器的实例。该迭代器可以正确遍历集合
  3. 抽象迭代器(Iterator)角色:定义访问和遍历聚合元素的接口,是java系统提供的,通常包含 hasNext()、remove()、next() 等方法。
  4. 具体迭代器(Concretelterator)角色:实现抽象迭代器接口中所定义的方法,完成对聚合对象的遍历,记录遍历的当前位置。
  5. Client :客户端, 通过 Iterator 和 Aggregate 依赖子类

其结构图类图:

image-20210417182430431

19.2.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package net.biancheng.c.iterator;

import java.util.*;
public class IteratorPattern {
public static void main(String[] args) {
Aggregate ag = new ConcreteAggregate();
ag.add("中山大学");
ag.add("华南理工");
ag.add("韶关学院");
System.out.print("聚合的内容有:");
Iterator it = ag.getIterator();
while (it.hasNext()) {
Object ob = it.next();
System.out.print(ob.toString() + "\t");
}
Object ob = it.first();
System.out.println("\nFirst:" + ob.toString());
}
}
//抽象聚合
interface Aggregate {
public void add(Object obj);
public void remove(Object obj);
public Iterator getIterator();
}
//具体聚合
class ConcreteAggregate implements Aggregate {
private List<Object> list = new ArrayList<Object>();
public void add(Object obj) {
list.add(obj);
}
public void remove(Object obj) {
list.remove(obj);
}
public Iterator getIterator() {
return (new ConcreteIterator(list));
}
}
//抽象迭代器
interface Iterator {
Object first();
Object next();
boolean hasNext();
}
//具体迭代器
class ConcreteIterator implements Iterator {
private List<Object> list = null;
private int index = -1;
public ConcreteIterator(List<Object> list) {
this.list = list;
}
public boolean hasNext() {
if (index < list.size() - 1) {
return true;
} else {
return false;
}
}
public Object first() {
index = 0;
Object obj = list.get(index);
;
return obj;
}
public Object next() {
Object obj = null;
if (this.hasNext()) {
obj = list.get(++index);
}
return obj;
}
}

19.3、应用举例

编写程序展示一个学校院系结构:需求是这样,要在一个页面中展示出学校的院系组成,一个学校有多个学院, 一个学院有多个系。如图:

image-20210417183413954

19.3.1、使用传统方式解决需求

思路解析(类图)

image-20210415205605144

传统的方式的问题分析:

  1. 将学院看做是学校的子类,系是学院的子类,这样实际上是站在组织大小来进行分层次的
  2. 实际上我们的要求是 :在一个页面中展示出学校的院系组成,一个学校有多个学院,一个学院有多个系, 因此这种方案,不能很好实现的遍历的操作
  3. 解决方案:=> 迭代器模式

19.3.2、使用迭代器模式解决需求

原理类图:

image-20210417184732598

代码实现:

Department:专业。元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Department {
private String name;
private String desc;
public Department(String name, String desc) {
super();
this.name = name;
this.desc = desc;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDesc() {
return desc;
}
public void setDesc(String desc) {
this.desc = desc;
}
}

抽象迭代器(Iterator)角色:java自带的Iterator迭代器接口

ComputerCollegeIterator:计算机学院迭代器,实现了迭代器接口里的hasNext()与next()方法,具体迭代器(Concretelterator)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.Iterator;

public class ComputerCollegeIterator implements Iterator {
//这里我们需要Department 是以怎样的方式存放=>数组
Department[] departments;
int position = 0; //遍历的位置

public ComputerCollegeIterator(Department[] departments) {
this.departments = departments;
}
//判断是否还有下一个元素
@Override
public boolean hasNext() {
// TODO Auto-generated method stub
if(position >= departments.length || departments[position] == null) {
return false;
}else {

return true;
}
}
@Override
public Object next() {
// TODO Auto-generated method stub
Department department = departments[position];
position += 1;
return department;
}
//删除的方法,默认空实现
public void remove() {}
}

InfoColleageIterator:信息学院迭代器,实现了迭代器接口里的hasNext()与next()方法,具体迭代器(Concretelterator)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.Iterator;
import java.util.List;

public class InfoColleageIterator implements Iterator {
List<Department> departmentList; // 信息工程学院是以List方式存放系
int index = -1;//索引

public InfoColleageIterator(List<Department> departmentList) {
this.departmentList = departmentList;
}
//判断list中还有没有下一个元素
@Override
public boolean hasNext() {
// TODO Auto-generated method stub
if(index >= departmentList.size() - 1) {
return false;
} else {
index += 1;
return true;
}
}
@Override
public Object next() {
// TODO Auto-generated method stub
return departmentList.get(index);
}

//空实现remove
public void remove() {}
}

College:学院接口,里面有createIterator()方法返回一个迭代器。抽象聚合(Aggregate)角色

1
2
3
4
5
6
7
8
9
import java.util.Iterator;

public interface College {
public String getName();
//增加系的方法
public void addDepartment(String name, String desc);
//返回一个迭代器,遍历
public Iterator createIterator();
}

ComputerCollege:计算机学院,实现学院接口,里面对专业这个元素采用数组方式存储。具体聚合(ConcreteAggregate)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.Iterator;

public class ComputerCollege implements College {
Department[] departments;
int numOfDepartment = 0 ;// 保存当前数组的对象个数
public ComputerCollege() {
departments = new Department[5];
addDepartment("Java专业", " Java专业 ");
addDepartment("PHP专业", " PHP专业 ");
addDepartment("大数据专业", " 大数据专业 ");
}
@Override
public String getName() {
return "计算机学院";
}
@Override
public void addDepartment(String name, String desc) {
Department department = new Department(name, desc);
departments[numOfDepartment] = department;
numOfDepartment += 1;
}
@Override
public Iterator createIterator() {
return new ComputerCollegeIterator(departments);
}
}

InfoCollege:信息学院,实现学院接口,里面对专业这个元素采用集合方式存储。具体聚合(ConcreteAggregate)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class InfoCollege implements College {
List<Department> departmentList;
public InfoCollege() {
departmentList = new ArrayList<Department>();
addDepartment("信息安全专业", " 信息安全专业 ");
addDepartment("网络安全专业", " 网络安全专业 ");
addDepartment("服务器安全专业", " 服务器安全专业 ");
}
@Override
public String getName() {
return "信息工程学院";
}
@Override
public void addDepartment(String name, String desc) {
Department department = new Department(name, desc);
departmentList.add(department);
}
@Override
public Iterator createIterator() {
return new InfoColleageIterator(departmentList);
}
}

OutPutImpl:遍历实现对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.Iterator;
import java.util.List;

public class OutPutImpl {
//学院集合
List<College> collegeList;
public OutPutImpl(List<College> collegeList) {
this.collegeList = collegeList;
}
//遍历所有学院,然后调用printDepartment 输出各个学院的系
public void printCollege() {
//从collegeList 取出所有学院, Java 中的 List 已经实现Iterator
Iterator<College> iterator = collegeList.iterator();
while(iterator.hasNext()) {
//取出一个学院
College college = iterator.next();
System.out.println("=== "+college.getName() +"=====" );
printDepartment(college.createIterator()); //得到对应迭代器
}
}
//输出 学院输出 系
public void printDepartment(Iterator iterator) {
while(iterator.hasNext()) {
Department d = (Department)iterator.next();
System.out.println(d.getName());
}
}
}

Client:客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.ArrayList;
import java.util.List;

public class Client {
public static void main(String[] args) {
//创建学院
List<College> collegeList = new ArrayList<College>();
ComputerCollege computerCollege = new ComputerCollege();
InfoCollege infoCollege = new InfoCollege();

collegeList.add(computerCollege);
collegeList.add(infoCollege);

OutPutImpl outPutImpl = new OutPutImpl(collegeList);
outPutImpl.printCollege();
}
}

19.4、迭代器模式在JDK的应用与源码

JDK 的 ArrayList 集合中就使用了迭代器模式

类图:

image-20210417202305992

角色说明:

  • 内部类 Itr 充当具体实现迭代器 Iterator 的类, 作为 ArrayList 内部类
  • List 就是充当了聚合接口,含有一个 iterator() 方法,返回一个迭代器对象
  • ArrayList 是实现聚合接口 List 的子类,实现了 iterator()
  • Iterator 接口系统提供
  • 迭代器模式解决了 不同集合(ArrayList ,LinkedList) 统一遍历问题

代码分析:

image-20210417200928815

List接口,其中有获取迭代器Iterator的抽象方法,交给实现类去实现

image-20210417203037908

ArrayList实现了List接口,并实现了List接口的Iterator方法

image-20210417202736654

image-20210417203908088

在ArrayList中把元素对象存进了数组里面

image-20210417204155507

Itr为ArrayList的内部类,实现了Iterator接口,并实现了接口的next()方法与hasNext()方法,由于元素是定义在ArrayList当中的,直接使用即可。

image-20210417204819687

image-20210417205056646

另外实现List接口的实现类的LinkedList类

image-20210417210006152

LinkedList继承了AbstractSequentialList类实现了AbstractSequentialList类当中的Iterator迭代器方法

image-20210417210011958

image-20210417211542896

Enumerator实现了Iterator接口

image-20210417224627356

19.5、迭代器模式总结

主要优点如下:

  1. 访问一个聚合对象的内容而无须暴露它的内部表示。
  2. 遍历任务交由迭代器完成,这简化了聚合类。
  3. 支持以不同方式遍历一个聚合,甚至可以自定义迭代器的子类以支持新的遍历
  4. 增加新的聚合类和迭代器类都很方便,无须修改原有代码
  5. 封装性良好,为遍历不同的聚合结构提供一个统一的接口。

其主要缺点是:增加了类的个数,这在一定程度上增加了系统的复杂性。

在日常开发中,我们几乎不会自己写迭代器。除非需要定制一个自己实现的数据结构对应的迭代器,否则,开源框架提供的 API 完全够用

应用场景:

  1. 需要为聚合对象提供多种遍历方式时。
  2. 需要为遍历不同的聚合结构提供一个统一的接口时。
  3. 访问一个聚合对象的内容而无须暴露其内部细节的表示时。
  4. 当要展示一组相似对象,或者遍历一组相同对象时。

由于聚合与迭代器的关系非常密切,所以大多数语言在实现聚合类时都提供了迭代器类,因此大数情况下使用语言中已有的聚合类的迭代器就已经够了。

19.6、迭代器模式扩展

迭代器模式常常与组合模式结合起来使用,在对组合模式中的容器构件进行访问时,经常将迭代器潜藏在组合模式的容器构成类中。当然也可以构造一个外部迭代器来对容器构件进行访问,其结构图:

image-20210417200821530

19.7、进阶阅读

如果您想了解迭代器模式在框架源码中的应用,可猛击阅读以下文章。

19.8、相关设计模式

  • Visitor 模式

    Iterator模式是从集合中一个一个取出元素进行遍历, 但是并没有在Iterator接口中声明对取出的元素进行何种处理。

    Visitor模式则是在遍历元素集合的过程中, 对元素进行相同的处理。

    在遍历集合的过程中对元素进行固定的处理是常有的需求。 Visitor模式正是为了应对这种需求而出现的。 在访问元素集合的过程中对元素进行相同的处理, 这种模式就是Visitor模式。

  • Composite 模式

    Composite模式是具有递归结构的模式, 在其中使用Iterator模式比较困难。

  • Factory Method 模式

    在iterator方法中生成Iterator的实例时可能会使用Factory Method模式。

19.9、迭代器模式的注意事项与细节

提供了一种设计思想,就是一个类应该只有一个引起变化的原因(叫做单一责任原则)。在聚合类中,我们把迭代器分开,就是要把管理对象集合和遍历对象集合的责任分开,这样一来集合改变的话,只影响到聚合对象。而如果遍历方式改变的话,只影响到了迭代器。

20、观察者模式Observer(行为型模式)

20.1、基本介绍

  1. 观察者(Observer)模式的定义:指多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。这种模式有时又称作发布-订阅模式模型-视图模式,它是对象行为型模式

  2. 观察者模式类似订牛奶业务

  3. 奶站/气象局:Subject 用户/第三方网站:Observer

  4. Subject:登记注册、移除和通知

    • registerObserver 注 册
    • removeObserver 移 除
    • notifyObservers() 通知所有的注册的用户,根据不同需求,可以是更新数据,让用户来取,也可能是实施推送, 看具体需求定
    • Observer:接收输入

    观察者模式:对象之间多对一依赖的一种设计方案,被依赖的对象为 Subject,依赖的对象为 Observer,Subject

    通知 Observer 变化,比如这里的奶站是 Subject,是 1 的一方。用户时 Observer,是多的一方。

20.2、观察者模式的原理结构图-uml类图

实现观察者模式时要注意具体目标对象具体观察者对象之间不能直接调用,否则将使两者之间紧密耦合起来,这违反了面向对象的设计原则

20.2.1、模式的结构

观察者模式的主要角色如下:

  1. 抽象主题(Subject)角色:也叫抽象目标类,它提供了一个用于保存观察者对象的聚集类和增加、删除观察者对象的方法,以及通知所有观察者的抽象方法
  2. 具体主题(Concrete Subject)角色:也叫具体目标类,它实现抽象目标中的通知方法,当具体主题的内部状态发生改变时,通知所有注册过的观察者对象。
  3. 抽象观察者(Observer)角色:它是一个抽象类或接口,它包含了一个更新自己的抽象方法,当接到具体主题的更改通知时被调用。
  4. 具体观察者(Concrete Observer)角色:实现抽象观察者中定义的抽象方法,以便在得到目标的更改通知时更新自身的状态。

观察者模式的结构图:

image-20210417224339045

20.2.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package net.biancheng.c.observer;
import java.util.*;

public class ObserverPattern {
public static void main(String[] args) {
Subject subject = new ConcreteSubject();
Observer obs1 = new ConcreteObserver1();
Observer obs2 = new ConcreteObserver2();
subject.add(obs1);
subject.add(obs2);
subject.notifyObserver();
}
}
//抽象目标
abstract class Subject {
protected List<Observer> observers = new ArrayList<Observer>();
//增加观察者方法
public void add(Observer observer) {
observers.add(observer);
}
//删除观察者方法
public void remove(Observer observer) {
observers.remove(observer);
}
public abstract void notifyObserver(); //通知观察者方法
}
//具体目标
class ConcreteSubject extends Subject {
public void notifyObserver() {
System.out.println("具体目标发生改变...");
System.out.println("--------------");
for (Object obs : observers) {
((Observer) obs).response();
}
}
}
//抽象观察者
interface Observer {
void response(); //反应
}
//具体观察者1
class ConcreteObserver1 implements Observer {
public void response() {
System.out.println("具体观察者1作出反应!");
}
}
//具体观察者1
class ConcreteObserver2 implements Observer {
public void response() {
System.out.println("具体观察者2作出反应!");
}
}

20.3、应用举例

天气预报项目需求,具体要求如下:

  1. 气象站可以将每天测量到的温度,湿度,气压等等以公告的形式发布出去(比如发布到自己的网站或第三方)。
  2. 需要设计开放型 API,便于其他第三方也能接入气象站获取数据。
  3. 提供温度、气压和湿度的接口
  4. 测量数据更新时,要能实时的通知给第三方

20.3.1、使用传统方法解决需求

image-20210418001550797

image-20210418001617106

实现代码:

WeatherData:天气情况

image-20210418002547098

CurrentConditions

image-20210418002701455

Client

image-20210418002801559

问题分析:

  1. 其他第三方接入气象站获取数据的问题
  2. 无法在运行时动态的添加第三方 (新浪网站)
  3. 违反 ocp 原则=>观察者模式
1
2
3
4
//在 WeatherData 中,当增加一个第三方,都需要创建一个对应的第三方的公告板对象,并加入到 dataChange, 不利于维护,也不是动态加入
public void dataChange() {
currentConditions.update(getTemperature(), getPressure(), getHumidity());
}

20.3.2、使用观察者模式解决需求

类图说明:

image-20210418002150098

代码实现:

Subject:抽象主题(Subject)角色

1
2
3
4
5
6
//接口, 让WeatherData 来实现 
public interface Subject {
public void registerObserver(Observer o);
public void removeObserver(Observer o);
public void notifyObservers();
}

WeatherData:包含最新的天气情况信息。具体主题(Concrete Subject)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import java.util.ArrayList;

/**
* 类是核心
* 1. 包含最新的天气情况信息
* 2. 含有 观察者集合,使用ArrayList管理
* 3. 当数据有更新时,就主动的调用 ArrayList, 通知所有的(接入方)就看到最新的信息
* @author Administrator
*
*/
public class WeatherData implements Subject {
private float temperatrue;
private float pressure;
private float humidity;
//观察者集合
private ArrayList<Observer> observers;
//加入新的第三方
public WeatherData() {
observers = new ArrayList<Observer>();
}
public float getTemperature() {
return temperatrue;
}
public float getPressure() {
return pressure;
}
public float getHumidity() {
return humidity;
}
public void dataChange() {
//调用 接入方的 update
notifyObservers();
}
//当数据有更新时,就调用 setData
public void setData(float temperature, float pressure, float humidity) {
this.temperatrue = temperature;
this.pressure = pressure;
this.humidity = humidity;
//调用dataChange, 将最新的信息 推送给 接入方 currentConditions
dataChange();
}
//注册一个观察者
@Override
public void registerObserver(Observer o) {
// TODO Auto-generated method stub
observers.add(o);
}
//移除一个观察者
@Override
public void removeObserver(Observer o) {
if(observers.contains(o)) {
observers.remove(o);
}
}
//遍历所有的观察者,并通知
@Override
public void notifyObservers() {
for(int i = 0; i < observers.size(); i++) {
observers.get(i).update(this.temperatrue, this.pressure, this.humidity);
}
}
}

Observer:观察者接口,由观察者来实现。抽象观察者(Observer)角色

1
2
3
4
//观察者接口,由观察者来实现
public interface Observer {
public void update(float temperature, float pressure, float humidity);
}

CurrentConditions:当前环境。具体观察者(Concrete Observer)角色(百度、新浪等等第三方类似)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CurrentConditions implements Observer {
// 温度,气压,湿度
private float temperature;
private float pressure;
private float humidity;
// 更新 天气情况,是由 WeatherData 来调用,我使用推送模式
public void update(float temperature, float pressure, float humidity) {
this.temperature = temperature;
this.pressure = pressure;
this.humidity = humidity;
display();
}
// 显示
public void display() {
System.out.println("***Today mTemperature: " + temperature + "***");
System.out.println("***Today mPressure: " + pressure + "***");
System.out.println("***Today mHumidity: " + humidity + "***");
}
}

Client:客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Client {
public static void main(String[] args) {
//创建一个WeatherData
WeatherData weatherData = new WeatherData();
//创建观察者
CurrentConditions currentConditions = new CurrentConditions();
BaiduSite baiduSite = new BaiduSite();
//注册到weatherData
weatherData.registerObserver(currentConditions);
weatherData.registerObserver(baiduSite);
//测试
System.out.println("通知各个注册的观察者, 看看信息");
weatherData.setData(10f, 100f, 30.3f);
weatherData.removeObserver(currentConditions);
//测试
System.out.println();
System.out.println("通知各个注册的观察者, 看看信息");
weatherData.setData(10f, 100f, 30.3f);
}
}

20.4、观察者模式在JDK的应用与源码

Jdk 的 Observable 类就使用了观察者模式

角色分析:

  • Observable 的作用和地位等价于 我们前面讲过 Subject
  • Observable 是类,不是接口,类中已经实现了核心的方法 ,即管理 Observer 的方法 add.. delete .. notify…
  • Observer 的作用和地位等价于我们前面讲过的 Observer, 有 update
  • Observable 和 Observer 的使用方法和前面讲过的一样,只是 Observable 是类,通过继承来实现观察者模式

代码分析

image-20210418015159710

Observable:相当于Subject接口,但是Observable为一个普通的类

image-20210418015442922

image-20210418015737148

Observe接口:

image-20210418015636229

image-20210418020018759

20.5、观察者模式总结

主要优点如下:

  1. 降低了目标与观察者之间的耦合关系,两者之间是抽象耦合关系。符合依赖倒置原则
  2. 目标与观察者之间建立了一套触发机制

主要缺点如下:

  1. 如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃

    image-20210418011458744

  2. 观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。

  3. 观察者对象很多时,通知的发布会花费很多时间,影响程序的效率

在软件系统中,当系统一方行为依赖另一方行为的变动时,可使用观察者模式松耦合联动双方,使得一方的变动可以通知到感兴趣的另一方对象,从而让另一方对象对此做出响应。

观察者模式的应用情景:

  1. 对象间存在一对多关系一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少对象将发生改变,可以降低对象之间的耦合度。
  2. 一个对象必须通知其他对象,而并不知道这些对象是谁
  3. 一个抽象模型有两个方面,其中一个方面依赖于另一方面时,可将这二者封装在独立的对象中以使它们可以各自独立地改变和复用
  4. 实现类似广播机制的功能,不需要知道具体收听者,只需分发广播,系统中感兴趣的对象会自动接收该广播。
  5. 需要在系统中创建一个触发链,A对象的行为将影响B对象,B对象的行为将影响C对象……,多层级嵌套使用,形成一种链式触发机制,使得事件具备跨域(跨越两种观察者类型)通知。

20.6、观察者模式扩展

在 Java 中,通过 java.util.Observable 类和 java.util.Observer 接口定义了观察者模式,只要实现它们的子类就可以编写观察者模式实例。

20.6.1、 Observable类

Observable 类是抽象目标类,它有一个 Vector 向量用于保存所有要通知的观察者对象,下面来介绍它最重要的 3 个方法:

  1. void addObserver(Observer o) 方法:用于将新的观察者对象添加到向量中。
  2. void notifyObservers(Object arg) 方法:调用向量中的所有观察者对象的 update() 方法,通知它们数据发生改变。通常越晚加入向量的观察者越先得到通知。(类似于栈结构)
  3. void setChange() 方法:用来设置一个 boolean 类型的内部标志位,注明目标对象发生了变化。当它为真时,notifyObservers() 才会通知观察者。

20.6.2、 Observer 接口

Observer 接口是抽象观察者,它监视目标对象的变化,当目标对象发生变化时,观察者得到通知,并调用 void update(Observable o,Object arg) 方法,进行相应的工作。

20.6.3、对应例子

利用 Observable 类和 Observer 接口实现原油期货的观察者模式实例。

分析:当原油价格上涨时,空方伤心,多方局兴;当油价下跌时,空方局兴,多方伤心。本实例中的抽象目标(Observable)类在 Java 中已经定义,可以直接定义其子类,即原油期货(OilFutures)类,它是具体目标类,该类中定义一个 SetPriCe(float price) 方法,当原油数据发生变化时调用其父类的 notifyObservers(Object arg) 方法来通知所有观察者;另外,本实例中的抽象观察者接口(Observer)在 Java 中已经定义,只要定义其子类,即具体观察者类(包括多方类 Bull 和空方类 Bear),并实现 update(Observable o,Object arg) 方法即可。

相关类图:

image-20210418010916983

20.6.4、java. util.Observer接口和java. util . Observable类的相关解析

话虽如此,但是java. util.Observer接口和java. util . Observable类并不好用。理由很简单,传递给java. util . Observer接口的Subject角色必须是java . util. Observable类型(或者它的子类型)的。但Java只能单继承, 也就说如果Subject角色已经是某个类的子类了,那么它将无法继承java . util . Observable类。

20.7、进阶阅读

如果您想了解观察者模式在实际项目中的应用,可猛击阅读《基于Java API实现通知机制》文章。

20.8、相关设计模式

Mediator模式

  • 在Mediator模式中,有时会使用Observer模式来实现Mediator角色与Colleague角色之间的通信。
  • 就“发送状态变化通知”这一- 点而言,Mediator 模式与Observer模式是类似的。不过,两种模式中,通知的目的和视角不同。
  • 在Mediator模式中,虽然也会发送通知,不过那不过是为了对Colleague角色进行仲裁而已。
  • 而在Observer模式中,将Subject角色的状态变化通知给Observer 角色的目的则主要是为了使Subject角色和Observer角色同步。

20.9、观察者模式的注意事项与细节

  1. 观察者模式,又称发布-订阅模式,是一种一对多的通知机制,使得双方无需关心对方,只关心通知本身
  2. JAVA 中已经有了对观察者模式的支持类,但一般不支持使用。
  3. 避免循环引用
  4. 各个观察者是依次获得的同步通知,如果上一个观察者处理太慢,会导致下一个观察者不能及时获得通知。此外,如果观察者在处理通知的时候,发生了异常,还需要被观察者处理异常,才能保证继续通知下一个观察者。
  5. 如果顺序执行,某一观察者错误会导致系统卡壳,一般采用异步方式

21、中介者模式Mediator(行为型模式)

image-20210418021014591

21.1、基本介绍

  1. 中介者模式(Mediator Pattern),又称调停者模式,是迪米特法则的典型应用。用一个中介对象来封装一系列的对象交互。中介者使各个对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。它的目的是把多方会谈变成双方会谈,从而实现多方的松耦合。

  2. 中介者模式属于行为型模式,使代码易于维护

  3. 比如 MVC 模式,C(Controller 控制器)是 M(Model 模型)和 V(View 视图)的中介者,在前后端交互时起到了中间人的作用

    image-20210418141702195

21.2、中介者模式的原理结构图-uml类图

中介者模式实现的关键是找出“中介者”,下面对它的结构和实现进行分析。

21.2.1、模式的结构

中介者模式包含以下主要角色。

  1. 抽象中介者(Mediator)角色:它是中介者的接口,提供了同事对象注册与转发同事对象信息的抽象方法。
  2. 具体中介者(Concrete Mediator)角色:实现中介者接口,定义一个 List 来管理同事对象,协调各个同事角色之间的交互关系,因此它依赖于同事角色
  3. 抽象同事类(Colleague)角色:定义同事类的接口,保存中介者对象提供同事对象交互的抽象方法实现所有相互影响的同事类的公共功能
  4. 具体同事类(Concrete Colleague)角色:是抽象同事类的实现者,当需要与其他同事对象交互时,由中介者对象负责后续的交互。

image-20210418141912987

中介者模式的结构图如图:

image-20210418141852365

21.2.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package net.biancheng.c.mediator;
import java.util.*;

public class MediatorPattern {
public static void main(String[] args) {
Mediator md = new ConcreteMediator();
Colleague c1, c2;
c1 = new ConcreteColleague1();
c2 = new ConcreteColleague2();
md.register(c1);
md.register(c2);
c1.send();
System.out.println("-------------");
c2.send();
}
}
//抽象中介者
abstract class Mediator {
public abstract void register(Colleague colleague);
public abstract void relay(Colleague cl); //转发
}
//具体中介者
class ConcreteMediator extends Mediator {
private List<Colleague> colleagues = new ArrayList<Colleague>();
public void register(Colleague colleague) {
if (!colleagues.contains(colleague)) {
colleagues.add(colleague);
colleague.setMedium(this);
}
}
public void relay(Colleague cl) {
for (Colleague ob : colleagues) {
if (!ob.equals(cl)) {
((Colleague) ob).receive();
}
}
}
}
//抽象同事类
abstract class Colleague {
protected Mediator mediator;
public void setMedium(Mediator mediator) {
this.mediator = mediator;
}
public abstract void receive();

public abstract void send();
}
//具体同事类
class ConcreteColleague1 extends Colleague {
public void receive() {
System.out.println("具体同事类1收到请求。");
}
public void send() {
System.out.println("具体同事类1发出请求。");
mediator.relay(this); //请中介者转发
}
}
//具体同事类
class ConcreteColleague2 extends Colleague {
public void receive() {
System.out.println("具体同事类2收到请求。");
}
public void send() {
System.out.println("具体同事类2发出请求。");
mediator.relay(this); //请中介者转发
}
}

21.3、应用举例

智能家庭项目:

  1. 智能家庭包括各种设备,闹钟、咖啡机、电视机、窗帘 等
  2. 主人要看电视时,各个设备可以协同工作,自动完成看电视的准备工作
  3. 比如流程为:闹铃响起->咖啡机开始做咖啡->窗帘自动落下->电视机开始播放

21.3.1、使用传统方式解决需求

思路分析(类图)

image-20210418142259490

传统的方式的问题分析:

  1. 当各电器对象有多种状态改变时,相互之间的调用关系会比较复杂
  2. 各个电器对象彼此联系,你中有我,我中有你,不利于松耦合.
  3. 各个电器对象之间所传递的消息(参数),容易混乱当系统增加一个新的电器对象时,或者执行流程改变时,代码的可维护性、扩展性都不理想 =》 考虑中介者模式

21.3.2、使用中介者模式解决需求

思路分析(类图):

image-20210418142556064

代码实现:

Mediator:中介者。抽象中介者(Mediator)角色

1
2
3
4
5
6
7
8
public abstract class Mediator {
//将给中介者对象,加入到集合中
public abstract void Register(String colleagueName, Colleague colleague);
//接收消息, 具体的同事对象发出
public abstract void GetMessage(int stateChange, String colleagueName);
//发送消息
public abstract void SendMessage();
}

Colleague:抽象同事类(Colleague)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//同事抽象类
public abstract class Colleague {
private Mediator mediator;
public String name;
public Colleague(Mediator mediator, String name) {
this.mediator = mediator;
this.name = name;
}
public Mediator GetMediator() {
return this.mediator;
}
// 发送消息
public abstract void SendMessage(int stateChange);
}

ConcreteMediator:具体中介者(Concrete Mediator)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import java.util.HashMap;

//具体的中介者类
public class ConcreteMediator extends Mediator {
//集合,放入所有的同事对象
private HashMap<String, Colleague> colleagueMap;
private HashMap<String, String> interMap;
public ConcreteMediator() {
colleagueMap = new HashMap<String, Colleague>();
interMap = new HashMap<String, String>();
}
@Override
public void Register(String colleagueName, Colleague colleague) {
// 将具体的同事类放入集合中
colleagueMap.put(colleagueName, colleague);
if (colleague instanceof Alarm) {
interMap.put("Alarm", colleagueName);
} else if (colleague instanceof CoffeeMachine) {
interMap.put("CoffeeMachine", colleagueName);
} else if (colleague instanceof TV) {
interMap.put("TV", colleagueName);
} else if (colleague instanceof Curtains) {
interMap.put("Curtains", colleagueName);
}
}
//具体中介者的核心方法
//1. 根据得到消息,完成对应任务
//2. 中介者在这个方法,协调各个具体的同事对象,完成任务
@Override
public void GetMessage(int stateChange, String colleagueName) {
//处理闹钟发出的消息
if (colleagueMap.get(colleagueName) instanceof Alarm) {
if (stateChange == 0) {
((CoffeeMachine) (colleagueMap.get(interMap
.get("CoffeeMachine")))).StartCoffee();
((TV) (colleagueMap.get(interMap.get("TV")))).StartTv();
} else if (stateChange == 1) {
((TV) (colleagueMap.get(interMap.get("TV")))).StopTv();
}
//处理咖啡机发出的消息
} else if (colleagueMap.get(colleagueName) instanceof CoffeeMachine) {
((Curtains) (colleagueMap.get(interMap.get("Curtains"))))
.UpCurtains();
} else if (colleagueMap.get(colleagueName) instanceof TV) {//如果TV发现消息
} else if (colleagueMap.get(colleagueName) instanceof Curtains) {
//如果是以窗帘发出的消息,这里处理...
}
}
@Override
public void SendMessage() {
}
}

具体同事类(Concrete Colleague)角色:

Alarm:闹钟同事类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//具体的同事类
public class Alarm extends Colleague {
//构造器
public Alarm(Mediator mediator, String name) {
super(mediator, name);
//在创建Alarm 同事对象时,将自己放入到ConcreteMediator 对象中[集合]
mediator.Register(name, this);
}
public void SendAlarm(int stateChange) {
SendMessage(stateChange);
}
@Override
public void SendMessage(int stateChange) {
//调用的中介者对象的getMessage
this.GetMediator().GetMessage(stateChange, this.name);
}
}

CoffeeMachine:咖啡机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class CoffeeMachine extends Colleague {
public CoffeeMachine(Mediator mediator, String name) {
super(mediator, name);
mediator.Register(name, this);
}
@Override
public void SendMessage(int stateChange) {
this.GetMediator().GetMessage(stateChange, this.name);
}
public void StartCoffee() {
System.out.println("It's time to startcoffee!");
}
public void FinishCoffee() {
System.out.println("After 5 minutes!");
System.out.println("Coffee is ok!");
SendMessage(0);
}
}

TV:电视机、Curtains:窗帘类似

Client:客户端。负责调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Client {
public static void main(String[] args) {
//创建一个中介者对象
Mediator mediator = new ConcreteMediator();
//创建Alarm 并且加入到 ConcreteMediator 对象的HashMap
Alarm alarm = new Alarm(mediator, "alarm");
//创建了CoffeeMachine 对象,并且加入到ConcreteMediator对象的HashMap
CoffeeMachine coffeeMachine = new CoffeeMachine(mediator,"coffeeMachine");
//创建 Curtains , 并且加入到ConcreteMediator对象的HashMap
Curtains curtains = new Curtains(mediator, "curtains");
TV tV = new TV(mediator, "TV");
//让闹钟发出消息
alarm.SendAlarm(0);
coffeeMachine.FinishCoffee();
alarm.SendAlarm(1);
}
}

21.4、中介者模式总结

主要优点如下:

  1. 类之间各司其职,符合迪米特法则。
  2. 降低了对象之间的耦合性,使得对象易于独立地被复用。
  3. 对象间的一对多关联转变为一对一的关联,把多边关系变成多个双边关系,提高系统的灵活性,使得系统易于维护和扩展。

其主要缺点是:

  1. 中介者模式将原本多个对象直接的相互依赖变成了中介者和多个同事类的依赖关系
  2. 同事类越多时,中介者就会越臃肿,变得复杂且难以维护。

中介者模式的应用场景:

  • 对象之间存在复杂的网状结构关系而导致依赖关系混乱且难以复用时。
  • 当想创建一个运行于多个类之间的对象,又不想生成新的子类时。

21.5、中介者模式扩展

在实际开发中,通常采用以下两种方法来简化中介者模式,使开发变得更简单。

  1. 不定义中介者接口,把具体中介者对象实现成为单例
  2. 同事对象不持有中介者,而是在需要的时候直接获取中介者对象并调用

简化中介者模式的结构图:

image-20210418143949322

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package net.biancheng.c.mediator;
import java.util.*;

public class SimpleMediatorPattern {
public static void main(String[] args) {
SimpleColleague c1, c2;
c1 = new SimpleConcreteColleague1();
c2 = new SimpleConcreteColleague2();
c1.send();
System.out.println("-----------------");
c2.send();
}
}
//简单单例中介者
class SimpleMediator {
private static SimpleMediator smd = new SimpleMediator();
private List<SimpleColleague> colleagues = new ArrayList<SimpleColleague>();
private SimpleMediator() {
}
public static SimpleMediator getMedium() {
return (smd);
}
public void register(SimpleColleague colleague) {
if (!colleagues.contains(colleague)) {
colleagues.add(colleague);
}
}
public void relay(SimpleColleague scl) {
for (SimpleColleague ob : colleagues) {
if (!ob.equals(scl)) {
((SimpleColleague) ob).receive();
}
}
}
}
//抽象同事类
interface SimpleColleague {
void receive();
void send();
}
//具体同事类
class SimpleConcreteColleague1 implements SimpleColleague {
SimpleConcreteColleague1() {
SimpleMediator smd = SimpleMediator.getMedium();
smd.register(this);
}
public void receive() {
System.out.println("具体同事类1:收到请求。");
}
public void send() {
SimpleMediator smd = SimpleMediator.getMedium();
System.out.println("具体同事类1:发出请求...");
smd.relay(this); //请中介者转发
}
}
//具体同事类
class SimpleConcreteColleague2 implements SimpleColleague {
SimpleConcreteColleague2() {
SimpleMediator smd = SimpleMediator.getMedium();
smd.register(this);
}
public void receive() {
System.out.println("具体同事类2:收到请求。");
}
public void send() {
SimpleMediator smd = SimpleMediator.getMedium();
System.out.println("具体同事类2:发出请求...");
smd.relay(this); //请中介者转发
}
}

21.6、进阶阅读

如果您想了解中介者模式在JDK源码中的应用,可猛击阅读《中介者模式在JDK源码中的应用》文章。

21.7、相关设计模式

  • Facade模式

    在Mediator模式中,Mediator 角色与Colleague角色进行交互。

    而在Facade模式中,Facade 角色单方面地使用其他角色来对外提供高层接口( API)。因此,可以说Mediator模式是双向的,而Facade模式是单向的。

  • Observer模式

    有时会使用Observer模式来实现Mediator角色与Colleague 角色之间的通信。

21.8、中介者模式的注意事项与细节

  1. 多个类相互耦合,会形成网状结构,使用中介者模式将网状结构分离为星型结构进行解耦
  2. 减少类间依赖,降低了耦合,符合迪米特原则
  3. 中介者承担了较多的责任,一旦中介者出现了问题,整个系统就会受到影响
  4. 如果设计不当,中介者对象本身变得过于复杂,这点在实际使用时,要特别注意

22、备忘录模式Memento(行为型模式)

image-20210418145233471

22.1、基本介绍

  1. 备忘录模式(Memento Pattern),该模式又叫快照模式。在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。
  2. 可以这里理解备忘录模式:现实生活中的备忘录是用来记录某些要去做的事情,或者是记录已经达成的共同意见的事情,以防忘记了。而在软件层面,备忘录模式有着相同的含义,备忘录对象主要用来记录一个对象的某种状态,或者某些数据,当用户后悔时能撤销当前操作,使数据恢复到它原先的状态。
  3. 备忘录模式属于行为型模式。

22.2、备忘录模式的原理结构图-uml类图

备忘录模式的核心是设计备忘录类以及用于管理备忘录的管理者类。

22.2.1、模式的结构

备忘录模式的主要角色如下:

  1. 发起人(Originator)角色:记录当前时刻的内部状态信息,提供创建备忘录和恢复备忘录数据的功能,实现其他业务功能,它可以访问备忘录里的所有信息。

  2. 备忘录(Memento)角色:负责存储发起人的内部状态,在需要的时候提供这些内部状态给发起人。

    Memento角色有以下两种按口( API ):

    1. wide interface - 宽接口( API ):

      Memento角色提供的“宽接口( API)”是指所有用于获取恢复对象状态信息的方法的集合。由于宽接口( API)会暴露所有Memento角色的内部信息,因此能够使用宽接口( API)的只有Originator角色。

    2. narrowinterface - 窄接口 ( API ):

      Memento角色为外部的Caretaker角色提供了“窄接口( API)”。可以通过窄接口( API)获取的Memento角色的内部信息非常有限,因此可以有效地防止信息泄露。

    通过对外提供以上两种接口( API),可以有效地防止对象的封装性被破坏。

  3. 守护者(Caretaker)角色:对备忘录进行管理,提供保存与获取备忘录的功能,但其不能对备忘录的内容进行访问与修改。

说明:如果希望保存多个 originator 对象的不同时间的状态也可以,只需要在守护者Caretaker当中使用 HashMap <String, 集合>进行保存就行。

备忘录模式的结构图:

image-20210418152450078

22.2.2、代码实现

发起人(Originator)角色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Originator {
private String state;//状态信息
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
//编写一个方法,可以保存一个状态对象 Memento
//因此编写一个方法,返回 Memento
public Memento saveStateMemento() {
return new Memento(state);
}
//通过备忘录对象,恢复状态
public void getStateFromMemento(Memento memento) {
state = memento.getState();
}
}

备忘录(Memento)角色

1
2
3
4
5
6
7
8
9
10
11
public class Memento {
private String state;
//构造器
public Memento(String state) {
super();
this.state = state;
}
public String getState() {
return state;
}
}

守护者(Caretaker)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.ArrayList;
import java.util.List;

public class Caretaker {
//在List 集合中会有很多的备忘录对象
private List<Memento> mementoList = new ArrayList<Memento>();
public void add(Memento memento) {
mementoList.add(memento);
}
//获取到第index个Originator 的 备忘录对象(即保存状态)
public Memento get(int index) {
return mementoList.get(index);
}
}

Client:客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.ArrayList;
import java.util.HashMap;

public class Client {
public static void main(String[] args) {
Originator originator = new Originator();
Caretaker caretaker = new Caretaker();
// 状态#1
originator.setState(" 状态#1 攻击力 100 ");
//保存了当前的状态
caretaker.add(originator.saveStateMemento());
// 状态#2
originator.setState(" 状态#2 攻击力 80 ");
caretaker.add(originator.saveStateMemento());
// 状态#3
originator.setState(" 状态#3 攻击力 50 ");
caretaker.add(originator.saveStateMemento());

System.out.println("当前的状态是 =" + originator.getState());
//希望得到状态 1, 将 originator 恢复到状态1
originator.getStateFromMemento(caretaker.get(0));
System.out.println("恢复到状态1 , 当前的状态是");
System.out.println("当前的状态是 =" + originator.getState());
}
}

22.3、应用举例

游戏角色状态恢复问题:

游戏角色有攻击力和防御力,在大战 Boss 前保存自身的状态(攻击力和防御力),当大战 Boss 后攻击力和防御力下降,从备忘录对象恢复到大战前的状态。

22.3.1、使用传统模式解决需求

思路分析(类图):

image-20210418153109616

传统的方式的问题分析:

  1. 一个对象,就对应一个保存对象状态的对象, 这样当我们游戏的对象很多时,不利于管理,开销也很大。
  2. 传统的方式是简单地做备份,new 出另外一个对象出来,再把需要备份的数据放到这个新对象,但这就暴露了对象内部的细节
  3. 解决方案: => 备忘录模式

22.3.2、使用备忘录模式解决需求

思路分析和图解(类图):

image-20210418153258446

代码实现:

GameRole:游戏角色。发起人(Originator)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class GameRole {
private int vit;
private int def;
//创建Memento ,即根据当前的状态得到Memento
public Memento createMemento() {
return new Memento(vit, def);
}
//从备忘录对象,恢复GameRole的状态
public void recoverGameRoleFromMemento(Memento memento) {
this.vit = memento.getVit();
this.def = memento.getDef();
}
//显示当前游戏角色的状态
public void display() {
System.out.println("游戏角色当前的攻击力:" + this.vit + " 防御力: " + this.def);
}
public int getVit() {
return vit;
}
public void setVit(int vit) {
this.vit = vit;
}
public int getDef() {
return def;
}
public void setDef(int def) {
this.def = def;
}
}

Memento:备忘录(Memento)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Memento {
//攻击力
private int vit;
//防御力
private int def;
public Memento(int vit, int def) {
super();
this.vit = vit;
this.def = def;
}
public int getVit() {
return vit;
}
public void setVit(int vit) {
this.vit = vit;
}
public int getDef() {
return def;
}
public void setDef(int def) {
this.def = def;
}
}

Caretaker:守护者(Caretaker)角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.ArrayList;
import java.util.HashMap;
//守护者对象, 保存游戏角色的状态
public class Caretaker {
//如果只保存一次状态
private Memento memento;
//对GameRole 保存多次状态
//private ArrayList<Memento> mementos;
//对多个游戏角色保存多个状态
//private HashMap<String, ArrayList<Memento>> rolesMementos;

public Memento getMemento() {
return memento;
}
public void setMemento(Memento memento) {
this.memento = memento;
}
}

Client:客户端,负责调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Client {
public static void main(String[] args) {
//创建游戏角色
GameRole gameRole = new GameRole();
gameRole.setVit(100);
gameRole.setDef(100);
System.out.println("和boss大战前的状态");
gameRole.display();
//把当前状态保存caretaker
Caretaker caretaker = new Caretaker();
caretaker.setMemento(gameRole.createMemento());
System.out.println("和boss大战~~~");
gameRole.setDef(30);
gameRole.setVit(30);
gameRole.display();
System.out.println("大战后,使用备忘录对象恢复到大战前");
gameRole.recoverGameRoleFromMemento(caretaker.getMemento());
System.out.println("恢复后的状态");
gameRole.display();
}
}

22.4、备忘录模式总结

主要优点如下:

  • 提供了一种可以恢复状态的机制。当用户需要时能够比较方便地将数据恢复到某个历史的状态
  • 实现了内部状态的封装。除了创建它的发起人之外,其他对象都不能够访问这些状态信息。
  • 简化了发起人(Originator)类。发起人不需要管理和保存其内部状态的各个备份,所有状态信息都保存在备忘录中,并由管理者进行管理,这符合单一职责原则

主要缺点是:

  • 资源消耗大。如果要保存的内部状态信息过多或者特别频繁,将会占用比较大的内存资源。而且每一次保存都会消耗一定的内存

备忘录模式应用场景:

  1. 需要保存与恢复数据的场景,如玩游戏时的中间结果的存档功能。
  2. 需要提供一个可回滚操作的场景,如 Word、记事本、Photoshop,Eclipse 等软件在编辑时按 Ctrl+Z 组合键,还有数据库中事务操作。

备忘录模式应用实例:

  • 后悔药
  • 打游戏时的存档
  • Windows 里的 ctri + z
  • IE 中的后退
  • 数据库的事务管理
  • 编辑过程中的Undo(撤销)、Redo(重做)、History(历史记录)、Snapshot (快照)都是备忘录模式的应用

22.5、备忘录模式扩展

22.5.1、备忘录模式 + 原型模式

备忘录模式如何同原型模式混合使用。在备忘录模式中,通过定义“备忘录”来备份“发起人”的信息,而原型模式的 clone() 方法具有自备份功能,所以,如果让发起人实现 Cloneable 接口就有备份自己的功能,这时可以删除备忘录类,其结构图

image-20210418154508695

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package net.biancheng.c.memento;

public class PrototypeMemento {
public static void main(String[] args) {
OriginatorPrototype or = new OriginatorPrototype();
PrototypeCaretaker cr = new PrototypeCaretaker();
or.setState("S0");
System.out.println("初始状态:" + or.getState());
cr.setMemento(or.createMemento()); //保存状态
or.setState("S1");
System.out.println("新的状态:" + or.getState());
or.restoreMemento(cr.getMemento()); //恢复状态
System.out.println("恢复状态:" + or.getState());
}
}
//发起人原型
class OriginatorPrototype implements Cloneable {
private String state;
public void setState(String state) {
this.state = state;
}
public String getState() {
return state;
}
public OriginatorPrototype createMemento() {
return this.clone();
}
public void restoreMemento(OriginatorPrototype opt) {
this.setState(opt.getState());
}
public OriginatorPrototype clone() {
try {
return (OriginatorPrototype) super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return null;
}
}
//原型管理者
class PrototypeCaretaker {
private OriginatorPrototype opt;
public void setMemento(OriginatorPrototype opt) {
this.opt = opt;
}
public OriginatorPrototype getMemento() {
return opt;
}
}

22.5.2、关于备忘录模式在源码当中的应用

由于 JDK、Spring、Mybatis 中很少有备忘录模式,所以该设计模式不做典型应用源码分析。

Spring Webflow 中 DefaultMessageContext 类实现了 StateManageableMessageContext 接口,查看其源码可以发现其主要逻辑就相当于给 Message 备份

22.6、进阶阅读

如果您想了解备忘录模式在实际项目中的应用,可猛击阅读《使用备忘录模式实现草稿箱功能》文章。

22.7、相关设计模式

  • Command模式

    在使用Command模式处理命令时,可以使用Memento模式实现撤销功能。

  • Protype模式

    在Memento模式中,为了能够实现快照和撤销功能,保存了对象当前的状态。保存的信息只是在恢复状态时所需要的那部分信息。

    而在Protype模式中,会生成- 一个与当前实例完全相同的另外一个实例。 这两个实例的内容完全一样。

  • State 模式

    在Memento模式中,是用“实例”表示状态。

    而在State模式中,则是用“类”表示状态。

22.8、备忘录模式的注意事项与细节

  1. 给用户提供了一种可以恢复状态的机制,可以使用户能够比较方便地回到某个历史的状态

  2. 实现了信息的封装,使得用户不需要关心状态的保存细节

  3. 如果类的成员变量过多,势必会占用比较大的资源,而且每一次保存都会消耗一定的内存, 这个需要注意

  4. 为了节约内存,备忘录模式可以和原型模式配合使用

  5. 在守护者当中的不同情况:

    1. 如果只保存一次状态Memento

      1
      private Memento memento;
    2. 对发起人(Originator)对象保存多次状态Memento

      1
      private ArrayList<Memento> mementos;
    3. 对多个发起人(Originator)角色保存多个状态Memento

      1
      private HashMap<String, ArrayList<Memento>> rolesMementos;

23、解释器模式Interpreter(行为型模式)

image-20210418162744613

23.1、基本介绍

  1. 在编译原理中,一个算术表达式通过词法分析器形成词法单元,而后这些词法单元再通过语法分析器构建语法分析树,最终形成一颗抽象的语法分析树。这里的词法分析器和语法分析器都可以看做是解释器
  2. 解释器模式(Interpreter Pattern):是指给分析对象定义一个语言,并定义该语言的文法表示,再设计一个解析器来解释语言中的句子(表达式)。也就是说,用编译语言的方式来分析应用中的实例。
  3. 这种模式实现了文法表达式处理的接口,该接口解释一个特定的上下文。
  4. 这里提到的文法和句子的概念同编译原理中的描述相同,“文法”指语言的语法规则,而“句子”是语言集中的元素。

23.2、编译原理中的“文法、句子、语法树”等相关概念

23.2.1、文法

文法是用于描述语言的语法结构的形式规则。没有规矩不成方圆,例如,有些人认为完美爱情的准则是“相互吸引、感情专一、任何一方都没有恋爱经历”,虽然最后一条准则较苛刻,但任何事情都要有规则,语言也一样,不管它是机器语言还是自然语言,都有它自己的文法规则。

例如,中文中的“句子”的文法如下:

注:这里的符号“::=”表示“定义为”的意思,用“〈”和“〉”括住的是非终结符,没有括住的是终结符。

1
2
3
4
5
6
7
〈句子〉::=〈主语〉〈谓语〉〈宾语〉
〈主语〉::=〈代词〉|〈名词〉
〈谓语〉::=〈动词〉
〈宾语〉::=〈代词〉|〈名词〉
〈代词〉你|我|他
〈名词〉7大学生I筱霞I英语
〈动词〉::=是|学习

23.2.2、句子

句子是语言的基本单位,是语言集中的一个元素,它由终结符构成,能由“文法”推导出。

例如,上述文法可以推出“我是大学生”,所以它是句子。

23.2.3、语法树

语法树是句子结构的一种树型表示,它代表了句子的推导结果,它有利于理解句子语法结构的层次。

下图所示是“我是大学生”的语法树:

image-20210418180415987

解释器模式的结构与组合模式相似,不过其包含的组成元素比组合模式多,而且组合模式是对象结构型模式,而解释器模式是类行为型模式。

23.3、解释器模式的原理结构图-uml类图

23.3.1、模式的结构

解释器模式包含以下主要角色:

  1. 抽象表达式(Abstract Expression)角色:定义解释器的接口,约定解释器的解释操作,主要**包含解释方法 interpret()**,这个方法为抽象语法树中所有的节点所共享。
  2. 终结符表达式(Terminal Expression)角色:是抽象表达式的子类,用来实现文法中与终结符相关的操作,文法中的每一个终结符都有一个具体终结表达式与之相对应
  3. 非终结符表达式(Nonterminal Expression)角色:也是抽象表达式的子类,用来实现文法中与非终结符相关的操作,文法中的每条规则都对应于一个非终结符表达式
  4. 环境(Context)角色:通常包含各个解释器需要的数据或是公共的功能,一般用来传递被所有解释器共享的数据,后面的解释器可以从这里获取这些值
  5. 客户端(Client):主要任务是将需要分析的句子或表达式转换成使用解释器对象描述的抽象语法树,然后调用解释器的解释方法,当然也可以通过环境角色间接访问解释器的解释方法。

解释器模式的结构图:

image-20210418180640123

23.3.2、代码实现

解释器模式实现的关键是定义文法规则、设计终结符类与非终结符类、画出结构图,必要时构建语法树,其代码结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package net.biancheng.c.interpreter;

//抽象表达式类
interface AbstractExpression {
public void interpret(String info); //解释方法
}
//终结符表达式类
class TerminalExpression implements AbstractExpression {
public void interpret(String info) {
//对终结符表达式的处理
}
}
//非终结符表达式类
class NonterminalExpression implements AbstractExpression {
private AbstractExpression exp1;
private AbstractExpression exp2;
public void interpret(String info) {
//非对终结符表达式的处理
}
}
//环境类
class Context {
private AbstractExpression exp;
public Context() {
//数据初始化
}
public void operation(String info) {
//调用相关表达式类的解释方法
}
}

23.4、应用举例

四则运算问题:

通过解释器模式来实现四则运算,如计算 a+b-c 的值,具体要求:

  1. 先输入表达式的形式,比如 a+b+c-d+e, 要求表达式的字母不能重复

  2. 在分别输入 a ,b, c, d, e 的值

  3. 最后求出结果:如图

    image-20210418181009303

23.4.1、使用传统方式解决需求

传统方案解决四则运算问题分析:

  1. 编写一个方法,接收表达式的形式,然后根据用户输入的数值进行解析,得到结果
  2. 问题分析:如果加入新的运算符,比如*(乘)/(除) 等等,不利于扩展,另外让一个方法来解析会造成程序结构混乱,不够清晰。
  3. 解决方案:可以考虑使用解释器模式,即: 表达式 -> 解释器(可以有多种) -> 结果

23.4.2、使用解释器模式解决需求

思路分析和图解(类图):

image-20210418181353124

代码实现:

Expression:抽象表达式(Abstract Expression)

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.HashMap;
/**
* 抽象类表达式,通过HashMap 键值对, 可以获取到变量的值
*
* @author Administrator
*
*/
public abstract class Expression {
// a + b - c
// 解释公式和数值, key 就是公式(表达式) 参数[a,b,c], value就是就是具体值
// HashMap {a=10, b=20}
public abstract int interpreter(HashMap<String, Integer> var);
}

VarExpression:变量的解释器。终结符表达式(Terminal Expression)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.HashMap;
/**
* 变量的解释器
* @author Administrator
*
*/
public class VarExpression extends Expression {
private String key; // key=a,key=b,key=c
public VarExpression(String key) {
this.key = key;
}
// var 就是{a=10, b=20}
// interpreter 根据 变量名称,返回对应值
@Override
public int interpreter(HashMap<String, Integer> var) {
return var.get(this.key);
}
}

SymbolExpression:抽象运算符号解析器。非终结符表达式(Nonterminal Expression)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.HashMap;

/**
* 抽象运算符号解析器 这里,每个运算符号,都只和自己左右两个数字有关系,
* 但左右两个数字有可能也是一个解析的结果,无论何种类型,都是Expression类的实现类
*
* @author Administrator
*
*/
public class SymbolExpression extends Expression {
protected Expression left;
protected Expression right;
public SymbolExpression(Expression left, Expression right) {
this.left = left;
this.right = right;
}
//因为 SymbolExpression 是让其子类来实现,因此 interpreter 是一个默认实现
@Override
public int interpreter(HashMap<String, Integer> var) {
return 0;
}
}

AddExpression:加法解释器(减法解释器SubExpression类似)继承了SymbolExpression

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.HashMap;
/**
* 加法解释器
* @author Administrator
*
*/
public class AddExpression extends SymbolExpression {
public AddExpression(Expression left, Expression right) {
super(left, right);
}
//处理相加
//var 仍然是 {a=10,b=20}..
public int interpreter(HashMap<String, Integer> var) {
//super.left.interpreter(var) : 返回 left 表达式对应的值 a = 10
//super.right.interpreter(var): 返回right 表达式对应值 b = 20
return super.left.interpreter(var) + super.right.interpreter(var);
}
}

Calculator:计算器。环境(Context)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.HashMap;
import java.util.Stack;

public class Calculator {
// 定义表达式
private Expression expression;
// 构造函数传参,并解析
public Calculator(String expStr) { // expStr = a+b
// 安排运算先后顺序
Stack<Expression> stack = new Stack<>();
// 表达式拆分成字符数组
char[] charArray = expStr.toCharArray();// [a, +, b]
Expression left = null;
Expression right = null;
//遍历我们的字符数组, 即遍历 [a, +, b]
//针对不同的情况,做处理
for (int i = 0; i < charArray.length; i++) {
switch (charArray[i]) {
case '+': // '+'号
left = stack.pop();// 从stack取出left => "a"
right = new VarExpression(String.valueOf(charArray[++i]));// 取出右表达式 "b"
stack.push(new AddExpression(left, right));// 然后根据得到left 和 right 构建 AddExpresson加入stack
break;
case '-': // '-'号
left = stack.pop();
right = new VarExpression(String.valueOf(charArray[++i]));
stack.push(new SubExpression(left, right));
break;
default:
//如果是一个 Var 就创建要给 VarExpression 对象,并push到 stack
stack.push(new VarExpression(String.valueOf(charArray[i])));
break;
}
}
//当遍历完整个 charArray 数组后,stack 就得到最后Expression
this.expression = stack.pop();
}
public int run(HashMap<String, Integer> var) {
//最后将表达式a+b和 var = {a=10,b=20}
//然后传递给expression的interpreter进行解释执行
return this.expression.interpreter(var);
}
}

Client:客户端。负责调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;

public class Client {
public static void main(String[] args) throws IOException {
String expStr = getExpStr(); // a+b
HashMap<String, Integer> var = getValue(expStr);// var {a=10, b=20}
Calculator calculator = new Calculator(expStr);
System.out.println("运算结果:" + expStr + "=" + calculator.run(var));
}
// 获得表达式
public static String getExpStr() throws IOException {
System.out.print("请输入表达式:");
return (new BufferedReader(new InputStreamReader(System.in))).readLine();
}
// 获得值映射
public static HashMap<String, Integer> getValue(String expStr) throws IOException {
HashMap<String, Integer> map = new HashMap<>();
for (char ch : expStr.toCharArray()) {
if (ch != '+' && ch != '-') {
if (!map.containsKey(String.valueOf(ch))) {
System.out.print("请输入" + String.valueOf(ch) + "的值:");
String in = (new BufferedReader(new InputStreamReader(System.in))).readLine();
map.put(String.valueOf(ch), Integer.valueOf(in));
}
}
}
return map;
}
}

23.5、解释器模式在Spring框架的应用与源码

Spring 框架中 SpelExpressionParser 就使用到解释器模式

代码分析+Debug源码:

image-20210418194307766

main:

image-20210418194430819

Expression接口:

image-20210418194613016

image-20210418194717484

SpelExpressionParser的parseExpression()方法是继承了其父类TemplateAwareExpressionParser的parseExpression()方法,而TemplateAwareExpressionParser又实现了ExpressionParser接口

image-20210418194936179

image-20210418195208607

image-20210418195226794

image-20210418195314345

TemplateAwareExpressionParser的parseExpression()方法:

image-20210418195658830

其中的parseTemplate()方法

image-20210418195826923

其中的doParseTemplate()方法

image-20210418200021479

子类SpelExpressionParser实现了父类TemplateAwareExpressionParser的doParseTemplate()方法

image-20210418200257640

image-20210418200553239

说明:

image-20210418194009115

23.6、解释器模式总结

主要优点如下:

  1. 扩展性好。由于在解释器模式中使用类来表示语言的文法规则,因此可以通过继承等机制来改变或扩展文法
  2. 容易实现。在语法树中的每个表达式节点类都是相似的,所以实现其文法较为容易。

主要缺点如下:

  1. 执行效率较低。解释器模式中通常使用大量的循环和递归调用,当要解释的句子较复杂时,其运行速度很慢,且代码的调试过程也比较麻烦。
  2. 会引起类膨胀。解释器模式中的每条规则至少需要定义一个类,当包含的文法规则很多时,类的个数将急剧增加,导致系统难以管理与维护。
  3. 可应用的场景比较少。在软件开发中,需要定义语言文法的应用实例非常少,所以这种模式很少被使用到。

应用场景:

  1. 语言的文法较为简单,且执行效率不是关键问题时。
  2. 问题重复出现,且可以用一种简单的语言来进行表达时。
  3. 一个语言需要解释执行,并且语言中的句子可以表示为一个抽象语法树的时候,如 XML 文档解释

应用实例:编译器、运算表达式计算、正则表达式、机器人等

注意:解释器模式在实际的软件开发中使用比较少,因为它会引起效率、性能以及维护等问题。如果碰到对表达式的解释,在 Java 中可以用 Expression4J 或 Jep 等来设计。

23.7、解释器模式扩展

在项目开发中,如果要对数据表达式进行分析与计算,无须再用解释器模式进行设计了,Java 提供了以下强大的数学公式解析器:Expression4J、MESP(Math Expression String Parser) 和 Jep 等,它们可以解释一些复杂的文法,功能强大,使用简单。

现在以 Jep 为例来介绍该工具包的使用方法。Jep 是 Java expression parser 的简称,即 Java 表达式分析器,它是一个用来转换和计算数学表达式的 Java 库。通过这个程序库,用户可以以字符串的形式输入一个任意的公式,然后快速地计算出其结果。而且 Jep 支持用户自定义变量、常量和函数,它包括许多常用的数学函数和常量。

下面以计算存款利息为例来介绍。存款利息的计算公式是:本金x利率x时间=利息,其相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package net.biancheng.c.interpreter;
import com.singularsys.jep.*;

public class JepDemo {
public static void main(String[] args) throws JepException {
Jep jep = new Jep();
//定义要计算的数据表达式
String 存款利息 = "本金*利率*时间";
//给相关变量赋值
jep.addVariable("本金", 10000);
jep.addVariable("利率", 0.038);
jep.addVariable("时间", 2);
jep.parse(存款利息); //解析表达式
Object accrual = jep.evaluate(); //计算
System.out.println("存款利息:" + accrual);
}
}

23.8、进阶阅读

如果您想了解解释器模式在框架源码中的应用,可猛击阅读《解释器模式在JDK和Spring源码中的应用》文章。

23.9、相关设计模式

  • Composite模式

    NonterminalExpression角色多是递归结构,因此常会使用Composite模式来实现NonterminalExpression角色

  • Flyweight 模式

    有时会使用Flyweight模式来共享TerminalExpression角色。

  • Visitor 模式

    在推导出语法树后,有时会使用Visitor模式来访问语法树的各个节点。

23.10、解释器模式的注意事项与细节

  1. 当有一个语言需要解释执行,可将该语言中的句子表示为一个抽象语法树,就可以考虑使用解释器模式,让程序具有良好的扩展
  2. 使用解释器可能带来的问题:解释器模式会引起类膨胀、解释器模式采用递归调用方法,将会导致调试非常复杂、效率可能降低。
  3. 解释器模式通过抽象语法树实现对用户输入的解释执行。
  4. 解释器模式的实现通常非常复杂,且一般只能解决一类特定问题。

24、状态模式State(行为型模式)

image-20210418201124106

24.1、基本介绍

  1. 状态模式(State Pattern):它主要用来解决对象在多种状态转换时,需要对外输出不同的行为的问题。状态和行为是一一对应的,状态之间可以相互转换
  2. 对有状态的对象,把复杂的“判断逻辑”提取到不同的状态对象中,允许状态对象在其内部状态发生改变时改变其行为。
  3. 当一个对象的内在状态改变时,允许改变其行为,这个对象看起来像是改变了其类
  4. 替代了使用if-else解决问题

24.2、状态模式的原理结构图-uml类图

状态模式把受环境改变的对象行为包装在不同的状态对象里,其意图是让一个对象在其内部状态改变的时候,其行为也随之改变

24.2.1、模式的结构

状态模式包含以下主要角色:

  1. 环境类(Context)角色:也称为上下文,它定义了客户端需要的接口内部维护一个当前状态,并负责具体状态的切换
  2. 抽象状态(State)角色:定义一个接口,用以封装环境对象中的特定状态所对应的行为,可以有一个或多个行为。
  3. 具体状态(Concrete State)角色:实现抽象状态所对应的行为,并且在需要的情况下进行状态切换

其结构图类图:

image-20210418205430235

24.2.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class StatePatternClient {
public static void main(String[] args) {
Context context = new Context(); //创建环境
context.Handle(); //处理请求
context.Handle();
context.Handle();
context.Handle();
}
}
//环境类
class Context {
private State state;
//定义环境类的初始状态
public Context() {
this.state = new ConcreteStateA();
}
//设置新状态
public void setState(State state) {
this.state = state;
}
//读取状态
public State getState() {
return (state);
}
//对请求做处理
public void Handle() {
state.Handle(this);
}
}
//抽象状态类
abstract class State {
public abstract void Handle(Context context);
}
//具体状态A类
class ConcreteStateA extends State {
public void Handle(Context context) {
System.out.println("当前状态是 A.");
context.setState(new ConcreteStateB());
}
}
//具体状态B类
class ConcreteStateB extends State {
public void Handle(Context context) {
System.out.println("当前状态是 B.");
context.setState(new ConcreteStateA());
}
}

24.3、应用举例

APP 抽奖活动问题:

请编写程序完成 APP 抽奖活动 具体要求如下:

  1. 假如每参加一次这个活动要扣除用户 50 积分,中奖概率是 10%

  2. 奖品数量固定,抽完就不能抽奖

  3. 活动有四个状态:

    • 可以抽奖
    • 不能抽奖
    • 发放奖品
    • 奖品领完
  4. 活动的四个状态转换关系图:

    image-20210418205732564

24.3.1、使用传统方式解决需求

通常通过if/else判断抽奖的状态,从而实现不同的逻辑,伪代码如下

1
2
3
4
5
6
7
8
if(不能抽奖){
//代码逻辑
}else if(可以抽奖){
//代码逻辑
}else if(发放奖品){
//代码逻辑
}
//...

传统的方式的问题分析:

  • 这类代码难以应对变化,在添加一种状态时,我们需要手动添加if/else
  • 在添加一种功能时,要对所有的状态进行判断。
  • 因此代码会变得越来越臃肿,并且一旦没有处理某个状态,便会发生极其严重的BUG,难以维护
  • 不符合开闭原则

24.3.2、使用状态模式解决需求

思路分析和图解(类图)

  • 定义出一个接口叫状态接口,每个状态都实现它。
  • 接口有扣除积分方法、抽奖方法、发放奖品方法

image-20210418210441898

代码实现:

State:抽象状态(State)

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 状态抽象类
* @author Administrator
*/
public abstract class State {
// 扣除积分 - 50
public abstract void deductMoney();
// 是否抽中奖品
public abstract boolean raffle();
// 发放奖品
public abstract void dispensePrize();
}

具体状态(Concrete State):四种状态:

NoRaffleState:不能抽奖状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 不能抽奖状态
* @author Administrator
*/
public class NoRaffleState extends State {
// 初始化时传入活动引用,扣除积分后改变其状态
RaffleActivity activity;
public NoRaffleState(RaffleActivity activity) {
this.activity = activity;
}
// 当前状态可以扣积分 , 扣除后,将状态设置成可以抽奖状态
@Override
public void deductMoney() {
System.out.println("扣除50积分成功,您可以抽奖了");
activity.setState(activity.getCanRaffleState());
}
// 当前状态不能抽奖
@Override
public boolean raffle() {
System.out.println("扣了积分才能抽奖喔!");
return false;
}
// 当前状态不能发奖品
@Override
public void dispensePrize() {
System.out.println("不能发放奖品");
}
}

CanRaffleState:可以抽奖的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.Random;
/**
* 可以抽奖的状态
* @author Administrator
*/
public class CanRaffleState extends State {
RaffleActivity activity;
public CanRaffleState(RaffleActivity activity) {
this.activity = activity;
}
//已经扣除了积分,不能再扣
@Override
public void deductMoney() {
System.out.println("已经扣取过了积分");
}
//可以抽奖, 抽完奖后,根据实际情况,改成新的状态
@Override
public boolean raffle() {
System.out.println("正在抽奖,请稍等!");
Random r = new Random();
int num = r.nextInt(10);
// 10%中奖机会
if(num == 0){
// 改变活动状态为发放奖品 context
activity.setState(activity.getDispenseState());
return true;
}else{
System.out.println("很遗憾没有抽中奖品!");
// 改变状态为不能抽奖
activity.setState(activity.getNoRafflleState());
return false;
}
}
// 不能发放奖品
@Override
public void dispensePrize() {
System.out.println("没中奖,不能发放奖品");
}
}

DispenseState:发放奖品的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 发放奖品的状态
* @author Administrator
*/
public class DispenseState extends State {
// 初始化时传入活动引用,发放奖品后改变其状态
RaffleActivity activity;
public DispenseState(RaffleActivity activity) {
this.activity = activity;
}
@Override
public void deductMoney() {
System.out.println("不能扣除积分");
}
@Override
public boolean raffle() {
System.out.println("不能抽奖");
return false;
}
//发放奖品
@Override
public void dispensePrize() {
if(activity.getCount() > 0){
System.out.println("恭喜中奖了");
// 改变状态为不能抽奖
activity.setState(activity.getNoRafflleState());
}else{
System.out.println("很遗憾,奖品发送完了");
// 改变状态为奖品发送完毕, 后面我们就不可以抽奖
activity.setState(activity.getDispensOutState());
//System.out.println("抽奖活动结束");
//System.exit(0);
}
}
}

DispenseOutState:奖品发放完毕状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 奖品发放完毕状态
* 说明,当我们activity 改变成 DispenseOutState, 抽奖活动结束
* @author Administrator
*/
public class DispenseOutState extends State {
// 初始化时传入活动引用
RaffleActivity activity;
public DispenseOutState(RaffleActivity activity) {
this.activity = activity;
}
@Override
public void deductMoney() {
System.out.println("奖品发送完了,请下次再参加");
}
@Override
public boolean raffle() {
System.out.println("奖品发送完了,请下次再参加");
return false;
}
@Override
public void dispensePrize() {
System.out.println("奖品发送完了,请下次再参加");
}
}

Activity:抽奖活动。环境类(Context)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
* 抽奖活动
* @author Administrator
*/
public class Activity {
// state 表示活动当前的状态,是变化
State state = null;
// 奖品数量
int count = 0;
// 四个属性,表示四种状态
State noRafflleState = new NoRaffleState(this);
State canRaffleState = new CanRaffleState(this);
State dispenseState = new DispenseState(this);
State dispensOutState = new DispenseOutState(this);
//构造器
//1. 初始化当前的状态为 noRafflleState(即不能抽奖的状态)
//2. 初始化奖品的数量
public RaffleActivity( int count) {
this.state = getNoRafflleState();
this.count = count;
}
//扣分, 调用当前状态的 deductMoney
public void debuctMoney(){
state.deductMoney();
}
//抽奖
public void raffle(){
// 如果当前的状态是抽奖成功
if(state.raffle()){
//领取奖品
state.dispensePrize();
}
}
public State getState() {
return state;
}
public void setState(State state) {
this.state = state;
}
//这里请大家注意,每领取一次奖品,count--
public int getCount() {
int curCount = count;
count--;
return curCount;
}
public void setCount(int count) {
this.count = count;
}
public State getNoRafflleState() {
return noRafflleState;
}
public void setNoRafflleState(State noRafflleState) {
this.noRafflleState = noRafflleState;
}
public State getCanRaffleState() {
return canRaffleState;
}
public void setCanRaffleState(State canRaffleState) {
this.canRaffleState = canRaffleState;
}
public State getDispenseState() {
return dispenseState;
}
public void setDispenseState(State dispenseState) {
this.dispenseState = dispenseState;
}
public State getDispensOutState() {
return dispensOutState;
}
public void setDispensOutState(State dispensOutState) {
this.dispensOutState = dispensOutState;
}
}

Client:客户端,调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 状态模式测试类
* @author Administrator
*/
public class ClientTest {
public static void main(String[] args) {
// 创建活动对象,奖品有1个奖品
RaffleActivity activity = new RaffleActivity(1);
// 我们连续抽30次奖
for (int i = 0; i < 30; i++) {
System.out.println("--------第" + (i + 1) + "次抽奖----------");
// 参加抽奖,第一步点击扣除积分
activity.debuctMoney();
// 第二步抽奖
activity.raffle();
}
}
}

24.4、状态模式在实际项目-借贷平台源码分析

借贷平台的订单,有审核-发布-抢单 等等 步骤,随着操作的不同,会改变订单的状态, 项目中的这个模块实现就会使用到状态模式:

image-20210418213432465

image-20210418213517578

实现类图:

image-20210418213541498

代码实现:

State:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 状态接口
* @author Administrator
*/
public interface State {
/**
* 电审
*/
void checkEvent(Context context);
/**
* 电审失败
*/
void checkFailEvent(Context context);
/**
* 定价发布
*/
void makePriceEvent(Context context);
/**
* 接单
*/
void acceptOrderEvent(Context context);
/**
* 无人接单失效
*/
void notPeopleAcceptEvent(Context context);
/**
* 付款
*/
void payOrderEvent(Context context);
/**
* 接单有人支付失效
*/
void orderFailureEvent(Context context);
/**
* 反馈
*/
void feedBackEvent(Context context);
String getCurrentState();
}

AbstractState:实现State接口方法的默认实现。子类通过自己的需求进行重写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public abstract class AbstractState implements State {
rotected static final RuntimeException EXCEPTION = new RuntimeException("操作流程不允许");
@Override
public void checkEvent(Context context) {
throw EXCEPTION;
}
@Override
public void checkFailEvent(Context context) {
throw EXCEPTION;
}
@Override
public void makePriceEvent(Context context) {
throw EXCEPTION;
}
@Override
public void acceptOrderEvent(Context context) {
throw EXCEPTION;
}
@Override
public void notPeopleAcceptEvent(Context context) {
throw EXCEPTION;
}
@Override
public void payOrderEvent(Context context) {
throw EXCEPTION;
}
@Override
public void orderFailureEvent(Context context) {
throw EXCEPTION;
}
@Override
public void feedBackEvent(Context context) {
throw EXCEPTION;
}
}

各种具体状态类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//各种具体状态类
class FeedBackState extends AbstractState {
@Override
public String getCurrentState() {
return StateEnum.FEED_BACKED.getValue();
}
}
class GenerateState extends AbstractState {
@Override
public void checkEvent(Context context) {
context.setState(new ReviewState());
}
@Override
public void checkFailEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.GENERATE.getValue();
}
}
class NotPayState extends AbstractState {
@Override
public void payOrderEvent(Context context) {
context.setState(new PaidState());
}
@Override
public void feedBackEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.NOT_PAY.getValue();
}
}
class PaidState extends AbstractState {
@Override
public void feedBackEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.PAID.getValue();
}
}
class PublishState extends AbstractState {
@Override
public void acceptOrderEvent(Context context) {
context.setState(new NotPayState());
}
@Override
public void notPeopleAcceptEvent(Context context) {
context.setState(new FeedBackState());
}
@Override
public String getCurrentState() {
return StateEnum.PUBLISHED.getValue();
}
}
class ReviewState extends AbstractState {
@Override
public void makePriceEvent(Context context) {
context.setState(new PublishState());
}
@Override
public String getCurrentState() {
return StateEnum.REVIEWED.getValue();
}
}

StateEnum:状态枚举类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 状态枚举类
* @author Administrator
*/
public enum StateEnum {
//订单生成
GENERATE(1, "GENERATE"),
//已审核
REVIEWED(2, "REVIEWED"),
//已发布
PUBLISHED(3, "PUBLISHED"),
//待付款
NOT_PAY(4, "NOT_PAY"),
//已付款
PAID(5, "PAID"),
//已完结
FEED_BACKED(6, "FEED_BACKED");
private int key;
private String value;
StateEnum(int key, String value) {
this.key = key;
this.value = value;
}
public int getKey() {return key;}
public String getValue() {return value;}
}

Context:环境上下文

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
//环境上下文
public class Context extends AbstractState{
private State state;
@Override
public void checkEvent(Context context) {
state.checkEvent(this);
getCurrentState();
}
@Override
public void checkFailEvent(Context context) {
state.checkFailEvent(this);
getCurrentState();
}
@Override
public void makePriceEvent(Context context) {
state.makePriceEvent(this);
getCurrentState();
}
@Override
public void acceptOrderEvent(Context context) {
state.acceptOrderEvent(this);
getCurrentState();
}
@Override
public void notPeopleAcceptEvent(Context context) {
state.notPeopleAcceptEvent(this);
getCurrentState();
}
@Override
public void payOrderEvent(Context context) {
state.payOrderEvent(this);
getCurrentState();
}
@Override
public void orderFailureEvent(Context context) {
state.orderFailureEvent(this);
getCurrentState();
}
@Override
public void feedBackEvent(Context context) {
state.feedBackEvent(this);
getCurrentState();
}
public State getState() {
return state;
}
public void setState(State state) {
this.state = state;
}
@Override
public String getCurrentState() {
System.out.println("当前状态 : " + state.getCurrentState());
return state.getCurrentState();
}
}

ClientTest:测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**测试类*/
public class ClientTest {
public static void main(String[] args) {
Context context = new Context();
context.setState(new PublishState());
//然后可以根据操作变化状态.
//publish --> not pay
context.acceptOrderEvent(context);
//not pay --> paid
context.payOrderEvent(context);
// 失败, 检测失败时,会抛出异常
try {
context.checkFailEvent(context);
System.out.println("流程正常..");
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}

24.5、状态模式总结

主要优点如下:

  1. 结构清晰,状态模式将与特定状态相关的行为局部化到一个状态中,并且将不同状态的行为分割开来,满足“单一职责原则”
  2. 枚举可能的状态,在枚举状态之前需要确定状态种类。
  3. 将状态转换显示化,减少对象间的相互依赖。将不同的状态引入独立的对象中会使得状态转换变得更加明确,且减少对象间的相互依赖。
  4. 允许状态转换逻辑与状态对象合成一体,而不是某一个巨大的条件语句块。
  5. 所有与某个状态有关的行为放到一个类中,并且可以方便地增加新的状态,只需要改变对象状态即可改变对象的行为。
  6. 可以让多个环境对象共享一个状态对象,从而减少系统中对象的个数
  7. 状态类职责明确,有利于程序的扩展。通过定义新的子类很容易地增加新的状态和转换。

状态模式的主要缺点如下:

  1. 状态模式的使用必然会增加系统的类与对象的个数
  2. 状态模式的结构与实现都较为复杂,如果使用不当会导致程序结构和代码的混乱。
  3. 状态模式对开闭原则的支持并不太好,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态转换的源码,否则无法切换到新增状态,而且修改某个状态类的行为也需要修改对应类的源码。

状态模式的应用场景:

  • 当一个对象的行为取决于它的状态,并且它必须在运行时根据状态改变它的行为时,就可以考虑使用状态模式。
  • 一个操作中含有庞大的分支结构,并且这些分支决定于对象的状态时。
  • 一个事件或者对象有很多种状态状态之间会相互转换,对不同的状态要求有不同的行为的时候, 可以考虑使用状态模式

24.6、状态模式扩展

24.6.1、状态模式 + 享元模式

在有些情况下,可能有多个环境对象需要共享一组状态,这时需要引入享元模式,将这些具体状态对象放在集合中供程序共享,其结构图:

image-20210418212557965

分析:共享状态模式的不同之处是在环境类中增加了一个 HashMap 来保存相关状态,当需要某种状态时可以从中获取,其程序代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package state;
import java.util.HashMap;

public class FlyweightStatePattern {
public static void main(String[] args) {
ShareContext context = new ShareContext(); //创建环境
context.Handle(); //处理请求
context.Handle();
context.Handle();
context.Handle();
}
}
//环境类
class ShareContext {
private ShareState state;
private HashMap<String, ShareState> stateSet = new HashMap<String, ShareState>();
public ShareContext() {
state = new ConcreteState1();
stateSet.put("1", state);
state = new ConcreteState2();
stateSet.put("2", state);
state = getState("1");
}
//设置新状态
public void setState(ShareState state) {
this.state = state;
}
//读取状态
public ShareState getState(String key) {
ShareState s = (ShareState) stateSet.get(key);
return s;
}
//对请求做处理
public void Handle() {
state.Handle(this);
}
}
//抽象状态类
abstract class ShareState {
public abstract void Handle(ShareContext context);
}
//具体状态1类
class ConcreteState1 extends ShareState {
public void Handle(ShareContext context) {
System.out.println("当前状态是: 状态1");
context.setState(context.getState("2"));
}
}
//具体状态2类
class ConcreteState2 extends ShareState {
public void Handle(ShareContext context) {
System.out.println("当前状态是: 状态2");
context.setState(context.getState("1"));
}
}

24.6.2、状态模式与责任链模式的区别

  1. 状态模式和责任链模式都能消除 if-else 分支过多的问题。但在某些情况下,状态模式中的状态可以理解为责任,那么在这种情况下,两种模式都可以使用
  2. 定义来看,状态模式强调的是一个对象内在状态的改变,而责任链模式强调的是外部节点对象间的改变
  3. 代码实现上来看,两者最大的区别就是状态模式的各个状态对象知道自己要进入的下一个状态对象,而责任链模式并不清楚其下一个节点处理对象,因为链式组装由客户端负责

24.6.3、状态模式与策略模式的区别

状态模式和策略模式的 UML 类图架构几乎完全一样,但两者的应用场景是不一样的策略模式的多种算法行为择其一都能满足,彼此之间是独立的用户可自行更换策略算法,而状态模式的各个状态间存在相互关系,彼此之间在一定条件下存在自动切换状态的效果,并且用户无法指定状态,只能设置初始状态

24.7、进阶阅读

如果您想深入了解状态模式,可猛击阅读以下文章。

24.8、相关设计模式

  • Singleton 模式

    Singleton模式常常会出现在ConcreteState角色中。这是因为在表示状态的类中并没有定义任何实例字段(即表示实例的状态的字段)。

  • Flyweight 模式

    在表示状态的类中并没有定义任何实例字段。因此,有时我们可以使用Flyweight模式在多个Context角色之间共享ConcreteState角色。

24.9、状态模式的注意事项与细节

  1. 代码有很强的可读性。状态模式将每个状态的行为封装到对应的一个类
  2. 方便维护。将容易产生问题的 if-else 语句删除了,如果把每个状态的行为都放到一个类中,每次调用方法时都要判断当前是什么状态,不但会产出很多 if-else 语句,而且容易出错
  3. 会产生很多类。每个状态都要一个对应的类,当状态过多时会产生很多类,加大维护难度

25、策略模式Strategy(行为型模式)

image-20210418214750299

25.1、基本介绍

  1. 策略模式(Strategy Pattern)中,定义算法族(策略组),分别封装起来,让他们之间可以互相替换,此模式让算法的变化独立于使用算法的客户
  2. 策略模式属于对象行为模式,它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理
  3. 在策略模式中,我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的 context 对象。策略对象改变 context 对象的执行算法。
  4. 策略模式的核心思想是在一个计算方法中把容易变化的算法抽出来作为“策略”参数传进去,从而使得新增策略不必修改原有逻辑
  5. 这算法体现了几个设计原则:
    1. 把变化的代码从不变的代码中分离出来
    2. 第二、针对接口编程而不是具体类(定义了策略接口)
    3. 第三、多用组合/聚合,少用继承(客户通过组合方式使用策略)。

25.2、策略模式的原理结构图-uml类图

策略模式是准备一组算法,并将这组算法封装到一系列的策略类里面,作为一个抽象策略类的子类。策略模式的重心不是如何实现算法,而是如何组织这些算法,从而让程序结构更加灵活,具有更好的维护性和扩展性。

25.2.1、模式的结构

策略模式的主要角色如下:

  1. 抽象策略(Strategy)类:定义了一个公共接口各种不同的算法以不同的方式实现这个接口环境角色使用这个接口调用不同的算法,一般使用接口或抽象类实现。
  2. 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现。
  3. 环境(Context)类:持有一个策略类的引用,最终给客户端调用。

其结构图如图:

image-20210418225857983

25.3.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class StrategyPattern {
public static void main(String[] args) {
Context c = new Context();
Strategy s = new ConcreteStrategyA();
c.setStrategy(s);
c.strategyMethod();
System.out.println("-----------------");
s = new ConcreteStrategyB();
c.setStrategy(s);
c.strategyMethod();
}
}
//抽象策略类
interface Strategy {
public void strategyMethod(); //策略方法
}
//具体策略类A
class ConcreteStrategyA implements Strategy {
public void strategyMethod() {
System.out.println("具体策略A的策略方法被访问!");
}
}
//具体策略类B
class ConcreteStrategyB implements Strategy {
public void strategyMethod() {
System.out.println("具体策略B的策略方法被访问!");
}
}
//抽象策略类1
interface Strategy1 {
public void strategyMethod(); //策略方法
}
//具体策略类C
class ConcreteStrategyC implements Strategy1 {
public void strategyMethod() {
System.out.println("具体策略C的策略方法被访问!");
}
}
//具体策略类D
class ConcreteStrategyD implements Strategy1 {
public void strategyMethod() {
System.out.println("具体策略D的策略方法被访问!");
}
}
//环境类
class Context {
private Strategy strategy;
private Strategy1 strategy1;
public Strategy getStrategy() {
return strategy;
}
public void setStrategy(Strategy strategy) {
this.strategy = strategy;
}
public void strategyMethod() {
strategy.strategyMethod();
}
}

25.3、应用举例

编写鸭子项目,具体要求如下:

  1. 有各种鸭子(比如 野鸭、北京鸭、水鸭等, 鸭子有各种行为,比如 叫、飞行等)
  2. 显示鸭子的信息

25.3.1、使用传统方式解决需求

传统的设计方案(类图):

image-20210418230442078

传统的方式实现的问题分析和解决方案:

  1. 其它鸭子,都继承了 Duck 类,所以 fly 让所有子类都会飞了,这是不正确的
  2. 上面说的 1 的问题,其实是继承带来的问题:对类的局部改动,尤其超类的局部改动,会影响其他部分。会有溢出效应
  3. 为了改进 1 问题,我们可以通过覆盖 fly 方法来解决 => 覆盖解决
  4. 问题又来了,如果我们有一个玩具鸭子 ToyDuck, 这样就需要 ToyDuck 去覆盖 Duck 的所有实现的方法 => 解决思路 -》 策略模式 (strategy pattern)

25.3.2、使用策略模式解决需求

思路分析(类图):

策略模式:分别封装行为接口,实现算法族,超类里放行为接口对象,在子类里具体设定行为对象。

原则就是: 分离变化部分,封装接口,基于接口编程各种功能。此模式让行为的变化独立于算法的使用者。

image-20210418230737776

代码实现:

FlyBehavior:飞行。(QuackBehavior:叫行为。等等其它抽象策略与其具体实现类类似)抽象策略(Strategy)

1
2
3
public interface FlyBehavior {	
void fly(); // 子类具体实现
}

GoodFlyBehavior:飞行技术高超。(BadFlyBehavior:飞行技术一般、NoFlyBehavior:不会飞行等等类似)具体策略(Concrete Strategy)

1
2
3
4
5
6
public class GoodFlyBehavior implements FlyBehavior {
@Override
public void fly() {
System.out.println(" 飞翔技术高超 ~~~");
}
}

Duck:鸭子抽象类。环境(Context)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public abstract class Duck {
//属性, 策略接口
FlyBehavior flyBehavior;
//其它属性<->策略接口
QuackBehavior quackBehavior;
public Duck() {}
public abstract void display();//显示鸭子信息
public void quack() {
System.out.println("鸭子嘎嘎叫~~");
}
public void swim() {
System.out.println("鸭子会游泳~~");
}
public void fly() {
//改进
if(flyBehavior != null) {
flyBehavior.fly();
}
}
public void setFlyBehavior(FlyBehavior flyBehavior) {
this.flyBehavior = flyBehavior;
}
public void setQuackBehavior(QuackBehavior quackBehavior) {
this.quackBehavior = quackBehavior;
}
}

WildDuck:野鸭具体类,继承了鸭子抽象类。(PekingDuck:北京鸭,飞行技术一般、ToyDuck:玩具鸭,不会飞行类似)

1
2
3
4
5
6
7
8
9
10
public class WildDuck extends Duck {
//构造器,传入FlyBehavor 的对象
public WildDuck() {
flyBehavior = new GoodFlyBehavior();
}
@Override
public void display() {
System.out.println(" 这是野鸭 ");
}
}

Client:客户端,负责调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
public static void main(String[] args) {
WildDuck wildDuck = new WildDuck();
wildDuck.fly();
ToyDuck toyDuck = new ToyDuck();
toyDuck.fly();
PekingDuck pekingDuck = new PekingDuck();
pekingDuck.fly();
//动态改变某个对象的行为, 北京鸭 不能飞
pekingDuck.setFlyBehavior(new NoFlyBehavior());
System.out.println("北京鸭的实际飞翔能力");
pekingDuck.fly();
}

}

25.4、策略模式在JDK的应用与源码

JDK 的 Arrays 的 Comparator 就使用了策略模式

代码分析+Debug 源码:

image-20210419001920044

main:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import java.util.Arrays;
import java.util.Comparator;

public class Strategy {
public static void main(String[] args) {
//数组
Integer[] data = { 9, 1, 2, 8, 4, 3 };
// 实现降序排序,返回-1放左边,1放右边,0保持不变

// 说明
// 1. 实现了 Comparator 接口(策略接口) , 匿名类 对象 new Comparator<Integer>(){..}
// 2. 对象 new Comparator<Integer>(){..} 就是实现了 策略接口 的对象
// 3. public int compare(Integer o1, Integer o2){} 指定具体的处理方式
Comparator<Integer> comparator = new Comparator<Integer>() {
public int compare(Integer o1, Integer o2) {
if (o1 > o2) {
return -1;
} else {
return 1;
}
};
};
// 说明
/*
* public static <T> void sort(T[] a, Comparator<? super T> c) {
if (c == null) {
sort(a); //默认方法
} else {
if (LegacyMergeSort.userRequested)
legacyMergeSort(a, c); //使用策略对象c
else
// 使用策略对象c
TimSort.sort(a, 0, a.length, c, null, 0, 0);
}
}
*/
//方式1
Arrays.sort(data, comparator);
System.out.println(Arrays.toString(data)); // 降序排序

//方式2- 同时lambda 表达式实现 策略模式
Integer[] data2 = { 19, 11, 12, 18, 14, 13 };

Arrays.sort(data2, (var1, var2) -> {
if(var1.compareTo(var2) > 0) {
return -1;
} else {
return 1;
}
});
System.out.println("data2=" + Arrays.toString(data2));
}
}

Comparator:是一个接口,其中有一个compare的核心方法:告诉代码应该怎么去比较两个实例,然后根据比较结果进行排序

image-20210419000512228

image-20210419001129754

Array的sort排序方法

image-20210419001333373

25.5、 策略模式总结

主要优点如下:

  1. 多重条件语句不易维护,而使用策略模式可以避免使用多重条件语句,如 if…else 语句、switch…case 语句。
  2. 策略模式提供了一系列的可供重用的算法族恰当使用继承可以把算法族的公共代码转移到父类里面,从而避免重复的代码。
  3. 策略模式可以提供相同行为的不同实现客户可以根据不同时间或空间要求选择不同的实现
  4. 策略模式提供了对开闭原则的完美支持,可以在不修改原代码的情况下,灵活增加新算法。
  5. 策略模式把算法的使用放到环境类中,而算法的实现移到具体策略类中,实现了二者的分离

其主要缺点如下:

  1. 所有策略类都需要对外暴露。客户端必须理解所有策略算法的区别,以便适时选择恰当的算法类。
  2. 策略模式造成很多的策略类,增加维护难度

策略模式的应用场景:

策略模式在很多地方用到,如 Java SE 中的容器布局管理就是一个典型的实例,Java SE 中的每个容器都存在多种布局供用户选择。在程序设计中,通常在以下几种情况中使用策略模式较多。

  1. 一个系统需要动态地在几种算法中选择一种时,可将每个算法封装到策略类中。
  2. 一个类定义了多种行为,并且这些行为在这个类的操作中以多个条件语句的形式出现,可将每个条件分支移入它们各自的策略类中以代替这些条件语句
  3. 系统中各算法彼此完全独立,且要求对客户隐藏具体算法的实现细节时。
  4. 系统要求使用算法的客户不应该知道其操作的数据时,可使用策略模式来隐藏与算法相关的数据结构。
  5. 多个类只区别在表现行为不同,可以使用策略模式,在运行时动态选择具体要执行的行为。

25.6、策略模式扩展(策略模式+工厂模式)

在一个使用策略模式的系统中,当存在的策略很多时,客户端管理所有策略算法将变得很复杂,如果在环境类中使用策略工厂模式来管理这些策略类将大大减少客户端的工作复杂度,其结构图如图:

image-20210418232858259

25.7、进阶阅读

如果您想深入了解策略模式,可猛击阅读以下文章。

25.8、相关设计模式

  • Flyweight模式

    有时会使用Flyweight模式让多个地方可以共用ConcreteStrategy角色。

  • Abstract Factory模式

    使用Strategy模式可以整体地替换算法。

    使用Abstract Factory模式则可以整体地替换具体工厂、零件和产品。

  • State 模式

    使用Strategy模式和State模式都可以替换被委托对象,而且它们的类之间的关系也很相似。但是两种模式的目的不同。

    在Strategy模式中,ConcreteStrategy 角色是表示算法的类。在Strategy模式中,可以替换被委托对象的类。当然如果没有必要,也可以不替换。

    而在State模式中,ConcreteState角色是表示“状态”的类。在State模式中,每次状态变化时,被委托对象的类都必定会被替换。

25.9、策略模式的注意事项与细节

  1. 策略模式的关键是:分析项目中变化部分与不变部分
  2. 策略模式的核心思想是:多用组合/聚合 少用继承;用行为类组合,而不是行为的继承。更有弹性
  3. 体现了“对修改关闭,对扩展开放”原则,客户端增加行为不用修改原有代码,只要添加一种策略(或者行为) 即可,避免了使用多重转移语句(if..else if..else)
  4. 提供了可以替换继承关系的办法: 策略模式将算法封装在独立的 Strategy 类中使得你可以独立于其 Context 改变它,使它易于切换、易于理解、易于扩展
  5. 需要注意的是:每添加一个策略就要增加一个类,当策略过多是会导致类数目庞大
  6. 如果一个系统的策略多于四个,就需要考虑使用混合模式(策略模式+工厂模式),解决策略类膨胀的问题。

26、职责链模式Chain of Responsibility(行为型模式)

image-20210419002157100

26.1、基本介绍

  1. 职责链模式(Chain of Responsibility Pattern), 又叫责任链模式,为请求创建了一个接收者对象的链。这种模式对请求的发送者和接收者进行解耦
  2. 为了避免请求发送者与多个请求处理者耦合在一起,于是将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。
  3. 职责链模式通常每个接收者都包含对另一个接收者的引用(形成闭环)。如果一个对象不能处理该请求,那么它会把相同的请求传给下一个接收者,依此类推。
  4. 在责任链模式中,客户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递过程,请求会自动进行传递。所以责任链将请求的发送者和请求的处理者解耦了。
  5. 这种类型的设计模式属于行为型模式

26.2、职责链模式的原理结构图-uml类图

通常情况下,可以通过数据链表来实现职责链模式的数据结构。

26.2.1、模式的结构

职责链模式主要包含以下角色:

  1. 抽象处理者(Handler)角色:定义一个处理请求的接口,包含抽象处理方法和一个后继连接Handler对象。
  2. 具体处理者(Concrete Handler)角色:实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。从而形成一个职责链。
  3. 请求类(Request)角色 , 含义很多属性,表示一个请求
  4. 客户类(Client)角色:创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。

责任链模式的本质是解耦请求与处理,让请求在处理链中能进行传递与被处理;理解责任链模式应当理解其模式,而不是其具体实现。责任链模式的独到之处是将其节点处理者组合成了链式结构,并允许节点自身决定是否进行请求处理或转发,相当于让请求流动起来。

其结构图如图:

image-20210419020914621

客户端可按下图所示设置责任链:

image-20210419021241297

26.2.2、代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package chainOfResponsibility;

public class ChainOfResponsibilityPattern {
public static void main(String[] args) {
//组装责任链
Handler handler1 = new ConcreteHandler1();
Handler handler2 = new ConcreteHandler2();
handler1.setNext(handler2);
//提交请求
handler1.handleRequest("two");
}
}
//抽象处理者角色
abstract class Handler {
private Handler next;
public void setNext(Handler next) {
this.next = next;
}
public Handler getNext() {
return next;
}
//处理请求的方法
public abstract void handleRequest(String request);
}
//具体处理者角色1
class ConcreteHandler1 extends Handler {
public void handleRequest(String request) {
if (request.equals("one")) {
System.out.println("具体处理者1负责处理该请求!");
} else {
if (getNext() != null) {
getNext().handleRequest(request);
} else {
System.out.println("没有人处理该请求!");
}
}
}
}
//具体处理者角色2
class ConcreteHandler2 extends Handler {
public void handleRequest(String request) {
if (request.equals("two")) {
System.out.println("具体处理者2负责处理该请求!");
} else {
if (getNext() != null) {
getNext().handleRequest(request);
} else {
System.out.println("没有人处理该请求!");
}
}
}
}

在上面代码中,我们把消息硬编码为 String 类型,而在真实业务中,消息是具备多样性的,可以是 int、String 或者自定义类型。因此,在上面代码的基础上,可以对消息类型进行抽象 Request,增强了消息的兼容性

26.3、应用举例

学校 OA 系统的采购审批项目:需求是

采购员采购教学器材

  1. 如果金额 小于等于 5000, 由教学主任审批 (0<=x<=5000)
  2. 如果金额 小于等于 10000, 由院长审批 (5000<x<=10000)
  3. 如果金额 小于等于 30000, 由副校长审批 (10000<x<=30000)
  4. 如果金额 超过 30000 以上,有校长审批 ( 30000<x)

请设计程序完成采购审批项目

26.3.1、使用传统方法解决需求

思路分析(类图):

image-20210419022414374

传统方案解决 OA 系统审批问题分析:

传统方式是:接收到一个采购请求后,根据采购金额来调用对应的 Approver (审批人)完成审批。

传统方式的问题分析 :

  1. 客户端这里会使用到分支判断(比如 switch) 来对不同的采购请求处理, 这样就存在如下问题:
    1. 如果各个级别的人员审批金额发生变化,在客户端的也需要变化
    2. 客户端必须明确的知道 有多少个审批级别和访问
  2. 这样 对一个采购请求进行处理 和 Approver (审批人) 就存在强耦合关系,不利于代码的扩展和维护
  3. 解决方案 =》 职责链模式

26.3.2、使用职责链模式解决需求

思路分析和图解(类图):

image-20210419022837598

代码实现:

PurchaseRequest:采购请求。请求类(Request)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//请求类
public class PurchaseRequest {
private int type = 0; //请求类型
private float price = 0.0f; //请求金额
private int id = 0;
//构造器
public PurchaseRequest(int type, float price, int id) {
this.type = type;
this.price = price;
this.id = id;
}
public int getType() {
return type;
}
public float getPrice() {
return price;
}
public int getId() {
return id;
}
}

Approver:抽象处理者(Handler)

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class Approver {
Approver approver; //下一个处理者
String name; // 名字
public Approver(String name) {
this.name = name;
}
//下一个处理者
public void setApprover(Approver approver) {
this.approver = approver;
}
//处理审批请求的方法,得到一个请求, 处理是子类完成,因此该方法做成抽象
public abstract void processRequest(PurchaseRequest purchaseRequest);
}

DepartmentApprover:教学主任处理类。(其中CollegeApprover:院长处理类(5000<x<=10000)、ViceSchoolMasterApprover:副校长处理类(10000<x<=30000)、SchoolMasterApprover:院长处理类(30000<x)类似)具体处理者(Concrete Handler)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DepartmentApprover extends Approver {
public DepartmentApprover(String name) {
// TODO Auto-generated constructor stub
super(name);
}
@Override
public void processRequest(PurchaseRequest purchaseRequest) {
if(purchaseRequest.getPrice() <= 5000) {
System.out.println(" 请求编号 id= " + purchaseRequest.getId() + " 被 " + this.name + " 处理");
}else {
approver.processRequest(purchaseRequest);
}
}
}

Client:客户类(Client)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//创建一个请求
PurchaseRequest purchaseRequest = new PurchaseRequest(1, 31000, 1);
//创建相关的审批人
DepartmentApprover departmentApprover = new DepartmentApprover("张主任");
CollegeApprover collegeApprover = new CollegeApprover("李院长");
ViceSchoolMasterApprover viceSchoolMasterApprover = new ViceSchoolMasterApprover("王副校");
SchoolMasterApprover schoolMasterApprover = new SchoolMasterApprover("佟校长");
//需要将各个审批级别的下一个设置好 (处理人构成环形: )
departmentApprover.setApprover(collegeApprover);
collegeApprover.setApprover(viceSchoolMasterApprover);
viceSchoolMasterApprover.setApprover(schoolMasterApprover);
schoolMasterApprover.setApprover(departmentApprover);

departmentApprover.processRequest(purchaseRequest);
viceSchoolMasterApprover.processRequest(purchaseRequest);
}
}

26.4、职责链模式在SpringMVC框架的应用与源码

SpringMVC-HandlerExecutionChain 类就使用到职责链模式

SpringMVC 请求流程简图:

image-20210419011833309

代码分析+Debug 源码:

image-20210419012005876

main:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import org.springframework.web.servlet.HandlerExecutionChain;
import org.springframework.web.servlet.HandlerInterceptor;

public class ResponsibilityChain {
public static void main(String[] args) {
// DispatcherServlet
//说明
/*
*
* protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
* HandlerExecutionChain mappedHandler = null;
* mappedHandler = getHandler(processedRequest);//获取到HandlerExecutionChain对象
* //在 mappedHandler.applyPreHandle 内部 得到啦 HandlerInterceptor interceptor
* //调用了拦截器的 interceptor.preHandle
* if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}

//说明:mappedHandler.applyPostHandle 方法内部获取到拦截器,并调用
//拦截器的 interceptor.postHandle(request, response, this.handler, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
* }
*
*
* //说明:在 mappedHandler.applyPreHandle内部中,
* 还调用了 triggerAfterCompletion 方法,该方法中调用了
* HandlerInterceptor interceptor = getInterceptors()[i];
try {
interceptor.afterCompletion(request, response, this.handler, ex);
}
catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception", ex2);
}
*/
}
}

SpringMVC中的最重要的DispatcherServlet类,当中有一个核心方法:doDispatcher方法

image-20210419012029050

在doDispatcher方法中一开始就获取了HandlerExecutionChain对象

image-20210419012220314

调用preHandle方法:

在得到HandlerExecutionChain对象后调用了其applyPreHandle()方法,在其内部得到了HandlerInterceptor interceptor拦截器并调用了拦截器的interceptor.preHandle方法。调用成功就返回。

image-20210419012326046

在applyPreHandle()中通过getInterceptors( ) [i] ;方法从拦截器数组当中获取对应的拦截器,并调用了拦截器的preHandle方法。

image-20210419012558289

调用postHandle方法:

在doDispatcher方法的applyPreHandle()下面:HandlerExecutionChain对象还调用了其applyPostHandle()方法

image-20210419013424983

在applyPostHandle()中通过getInterceptors() [i] ;方法从拦截器数组当中获取对应的拦截器,并调用了拦截器的postHandle方法。

image-20210419013555142

调用afterCompletion方法:

triggerAfterCompletion方法中得到了拦截器HandlerInterceptor并调用了拦截器的interceptor.afterCompletion方法

image-20210419014131628

image-20210419014819146

对源码总结

  1. springmvc 请求的流程图中,执行了 拦截器相关方法 interceptor.preHandler 等等
  2. 在处理 SpringMvc 请求时,使用到职责链模式还使用到适配器模式
  3. HandlerExecutionChain 主要负责的是请求拦截器的执行和请求处理,但是他本身不处理请求,只是将请求分配给链上注册处理器执行,这是职责链实现方式,减少职责链本身与处理逻辑之间的耦合,规范了处理流程
  4. HandlerExecutionChain 维护了 HandlerInterceptor 的集合, 可以向其中注册相应的拦截器.

26.5、职责链模式总结

主要优点如下:

  1. 降低了对象之间的耦合度。该模式使得一个对象无须知道到底是哪一个对象处理其请求以及链的结构,发送者和接收者也无须拥有对方的明确信息。
  2. 增强了系统的可扩展性。可以根据需要增加新的请求处理类,满足开闭原则
  3. 增强了给对象指派职责的灵活性。当工作流程发生变化,可以动态地改变链内的成员或者调动它们的次序,也可动态地新增或者删除责任
  4. 责任链简化了对象之间的连接每个对象只需保持一个指向其后继者的引用,不需保持其他所有处理者的引用,这避免了使用众多的 if 或者 if···else 语句
  5. 责任分担。每个类只需要处理自己该处理的工作,不该处理的传递给下一个对象完成,明确各类的责任范围,符合类的单一职责原则

主要缺点如下:

  1. 不能保证每个请求一定被处理。由于一个请求没有明确的接收者,所以不能保证它一定会被处理,该请求可能一直传到链的末端都得不到处理。所以最好形成闭环调用,保证请求一定可以得到调用。
  2. 对比较长的职责链,请求的处理可能涉及多个处理对象系统性能将受到一定影响
  3. 职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用
  4. 可能不容易观察运行时的特征,有碍于除错。

模式的应用场景:

  1. 多个对象可以处理一个请求,但具体由哪个对象处理该请求在运行时自动确定。
  2. 动态指定一组对象处理请求,或添加新的处理者
  3. 需要在不明确指定请求处理者的情况下,向多个处理者中的一个提交请求

应用实例:

  1. JS 中的事件冒泡。
  2. JAVA WEB 中 Apache Tomcat 对 Encoding 的处理
  3. Struts2 的拦截器
  4. jsp servlet 的 Filter
  5. 责任链模式经常用在拦截、预处理请求等。

26.6、职责链模式扩展

职责链模式存在以下两种情况:

  1. 纯的职责链模式:一个请求必须被某一个处理者对象所接收,且一个具体处理者对某个请求的处理只能采用以下两种行为之一:自己处理(承担责任);把责任推给下家处理。
  2. 不纯的职责链模式:允许出现某一个具体处理者对象在承担了请求的一部分责任后又将剩余的责任传给下家的情况,且一个请求可以最终不被任何接收端对象所接收

26.7、进阶阅读

如果您想深入了解责任链模式,可猛击阅读以下文章。

26.8、相关设计模式

  • Composite模式

    Handler角色经常会使用Composite模式。

  • Command模式

    有时会使用Command模式向Handler角色发送请求。

26.9、职责链模式的注意事项与细节

  1. 将请求和处理分开,实现解耦,提高系统的灵活性
  2. 简化了对象,使对象不需要知道链的结构
  3. 性能会受到影响,特别是在链比较长的时候,因此需控制链中最大节点数量,一般通过在 Handler 中设置一个最大节点数量在 setNext()方法中判断是否已经超过阀值,超过则不允许该链建立,避免出现超长链无意识地破坏系统性能
  4. 调试不方便。采用了类似递归的方式,调试时逻辑可能比较复杂
  5. 最佳应用场景:有多个对象可以处理同一个请求时,比如:多级请求、请假/加薪等审批流程、Java Web 中 Tomcat对 Encoding 的处理、拦截器

27、创建型模式的特点和分类

创建型模式的主要关注点是“怎样创建对象?”,它的主要特点是“将对象的创建与使用分离”。这样可以降低系统的耦合度,使用者不需要关注对象的创建细节,对象的创建由相关的工厂来完成。就像我们去商场购买商品时,不需要知道商品是怎么生产出来一样,因为它们由专门的厂商生产。

创建型模式分为以下几种:

  • 单例(Singleton)模式:某个类只能生成一个实例,该类提供了一个全局访问点供外部获取该实例,其拓展是有限多例模式。
  • 原型(Prototype)模式:将一个对象作为原型,通过对其进行复制而克隆出多个和原型类似的新实例。
  • 工厂方法(FactoryMethod)模式:定义一个用于创建产品的接口,由子类决定生产什么产品。
  • 抽象工厂(AbstractFactory)模式:提供一个创建产品族的接口,其每个子类可以生产一系列相关的产品。
  • 建造者(Builder)模式:将一个复杂对象分解成多个相对简单的部分,然后根据不同需要分别创建它们,最后构建成该复杂对象。

以上 5 种创建型模式,除了工厂方法模式属于类创建型模式,其他的全部属于对象创建型模式

28、结构型模式概述(结构型模式的分类)

结构型模式描述如何将类或对象按某种布局组成更大的结构。它分为类结构型模式对象结构型模式前者采用继承机制来组织接口和类后者釆用组合或聚合来组合对象

由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象结构型模式比类结构型模式具有更大的灵活性

结构型模式分为以下 7 种:

  1. 代理(Proxy)模式:为某对象提供一种代理以控制对该对象的访问。即客户端通过代理间接地访问该对象,从而限制、增强或修改该对象的一些特性。
  2. 适配器(Adapter)模式:将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。
  3. 桥接(Bridge)模式:将抽象与实现分离,使它们可以独立变化。它是用组合关系代替继承关系来实现的,从而降低了抽象和实现这两个可变维度的耦合度。
  4. 装饰(Decorator)模式:动态地给对象增加一些职责,即增加其额外的功能。
  5. 外观(Facade)模式:为多个复杂的子系统提供一个一致的接口,使这些子系统更加容易被访问。
  6. 享元(Flyweight)模式:运用共享技术来有效地支持大量细粒度对象的复用。
  7. 组合(Composite)模式:将对象组合成树状层次结构,使用户对单个对象和组合对象具有一致的访问性。

以上 7 种结构型模式,除了适配器模式分为类结构型模式和对象结构型模式两种其他的全部属于对象结构型模式

29、行为型模式概述(行为型模式的分类)

行为型模式用于描述程序在运行时复杂的流程控制,即描述多个类或对象之间怎样相互协作共同完成单个对象都无法单独完成的任务,它涉及算法与对象间职责的分配。

行为型模式分为类行为模式和对象行为模式前者采用继承机制来在类间分派行为后者采用组合或聚合在对象间分配行为

由于组合关系或聚合关系比继承关系耦合度低,满足“合成复用原则”,所以对象行为模式比类行为模式具有更大的灵活性。

行为型模式是 GoF 设计模式中最为庞大的一类,它包含以下 11 种模式。

  1. 模板方法(Template Method)模式:定义一个操作中的算法骨架,将算法的一些步骤延迟到子类中,使得子类在可以不改变该算法结构的情况下重定义该算法的某些特定步骤。
  2. 策略(Strategy)模式:定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的改变不会影响使用算法的客户。
  3. 命令(Command)模式:将一个请求封装为一个对象,使发出请求的责任和执行请求的责任分割开。
  4. 职责链(Chain of Responsibility)模式:把请求从链中的一个对象传到下一个对象,直到请求被响应为止。通过这种方式去除对象之间的耦合。
  5. 状态(State)模式:允许一个对象在其内部状态发生改变时改变其行为能力。
  6. 观察者(Observer)模式:多个对象间存在一对多关系,当一个对象发生改变时,把这种改变通知给其他多个对象,从而影响其他对象的行为。
  7. 中介者(Mediator)模式:定义一个中介对象来简化原有对象之间的交互关系,降低系统中对象间的耦合度,使原有对象之间不必相互了解。
  8. 迭代器(Iterator)模式:提供一种方法来顺序访问聚合对象中的一系列数据,而不暴露聚合对象的内部表示。
  9. 访问者(Visitor)模式:在不改变集合元素的前提下,为一个集合中的每个元素提供多种访问方式,即每个元素有多个访问者对象访问。
  10. 备忘录(Memento)模式:在不破坏封装性的前提下,获取并保存一个对象的内部状态,以便以后恢复它。
  11. 解释器(Interpreter)模式:提供如何定义语言的文法,以及对语言句子的解释方法,即解释器。

以上 11 种行为型模式,除了模板方法模式和解释器模式是类行为型模式其他的全部属于对象行为型模式

30、一句话归纳设计模式

分类 设计模式 简述 一句话归纳 目的 生活案例
创建型设计模式 (简单来说就是用来创建对象的) 工厂模式(Factory Pattern) 不同条件下创建不同实例 产品标准化,生产更高效 封装创建细节 实体工厂
单例模式(Singleton Pattern) 保证一个类仅有一个实例,并且提供一个全局访问点 世上只有一个我 保证独一无二 CEO
原型模式(Prototype Pattern) 通过拷贝原型创建新的对象 拔一根猴毛,吹出千万个 高效创建对象 克隆
建造者模式(Builder Pattern) 用来创建复杂的复合对象 高配中配和低配,想选哪配就哪配 开放个性配置步骤 选配
结构型设计模式 (关注类和对象的组合) 代理模式(Proxy Pattern) 为其他对象提供一种代理以控制对这个对象的访问 没有资源没时间,得找别人来帮忙 增强职责 媒婆
外观模式(Facade Pattern) 对外提供一个统一的接口用来访问子系统 打开一扇门,通向全世界 统一访问入口 前台
装饰器模式(Decorator Pattern) 为对象添加新功能 他大舅他二舅都是他舅 灵活扩展、同宗同源 煎饼
享元模式(Flyweight Pattern) 使用对象池来减少重复对象的创建 优化资源配置,减少重复浪费 共享资源池 全国社保联网
组合模式(Composite Pattern) 将整体与局部(树形结构)进行递归组合,让客户端能够以一种的方式对其进行处理 人在一起叫团伙,心在一起叫团队 统一整体和个体 组织架构树
适配器模式(Adapter Pattern) 将原来不兼容的两个类融合在一起 万能充电器 兼容转换 电源适配
桥接模式(Bridge Pattern) 将两个能够独立变化的部分分离开来 约定优于配置 不允许用继承
行为型设计模式 (关注对象之间的通信) 模板模式(Template Pattern) 定义一套流程模板,根据需要实现模板中的操作 流程全部标准化,需要微调请覆盖 逻辑复用 把大象装进冰箱
策略模式(Strategy Pattern) 封装不同的算法,算法之间能互相替换 条条大道通罗马,具体哪条你来定 把选择权交给用户 选择支付方式
责任链模式(Chain of Responsibility Pattern) 拦截的类都实现统一接口,每个接收者都包含对下一个接收者的引用。将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止。 各人自扫门前雪,莫管他们瓦上霜 解耦处理逻辑 踢皮球
迭代器模式(Iterator Pattern) 提供一种方法顺序访问一个聚合对象中的各个元素 流水线上坐一天,每个包裹扫一遍 统一对集合的访问方式 逐个检票进站
命令模式(Command Pattern) 将请求封装成命令,并记录下来,能够撤销与重做 运筹帷幄之中,决胜千里之外 解耦请求和处理 遥控器
状态模式(State Pattern) 根据不同的状态做出不同的行为 状态驱动行为,行为决定状态 绑定状态和行为 订单状态跟踪
备忘录模式(Memento Pattern) 保存对象的状态,在需要时进行恢复 失足不成千古恨,想重来时就重来 备份、后悔机制 草稿箱
中介者模式(Mediator Pattern) 将对象之间的通信关联关系封装到一个中介类中单独处理,从而使其耦合松散 联系方式我给你,怎么搞定我不管 统一管理网状资源 朋友圈
解释器模式(Interpreter Pattern) 给定一个语言,定义它的语法表示,并定义一个解释器,这个解释器使用该标识来解释语言中的句子 我想说”方言“,一切解释权都归我 实现特定语法解析 摩斯密码
观察者模式(Observer Pattern) 状态发生改变时通知观察者,一对多的关系 到点就通知我 解耦观察者与被观察者 闹钟
访问者模式(Visitor Pattern) 稳定数据结构,定义新的操作行为 横看成岭侧成峰,远近高低各不同 解耦数据结构和数据操作 KPI考核
委派模式(Delegate Pattern) 允许对象组合实现与继承相同的代码重用,负责任务的调用和分配 这个需求很简单,怎么实现我不管 只对结果负责 授权委托书

31、其他设计模式(不属于23种)

  1. MVC 模式:Model-View-Controller(模型-视图-控制器) 模式。这种模式用于应用程序的分层开发。

    • Model(模型) - 模型代表一个存取数据的对象或 JAVA POJO。它也可以带有逻辑,在数据变化时更新控制器。

    • View(视图) - 视图代表模型包含的数据的可视化。

    • Controller(控制器) - 控制器作用于模型和视图上。它控制数据流向模型对象,并在数据变化时更新视图。它使视图与模型分离开。

      image-20210419025540539

  2. 业务代表模式(Business Delegate Pattern):用于对表示层和业务层解耦。它基本上是用来减少通信或对表示层代码中的业务层代码的远程查询功能。在业务层中我们有以下实体:

    • 客户端(Client) - 表示层代码可以是 JSP、servlet 或 UI java 代码。
    • 业务代表(Business Delegate) - 一个为客户端实体提供的入口类,它提供了对业务服务方法的访问。
    • 查询服务(LookUp Service) - 查找服务对象负责获取相关的业务实现,并提供业务对象对业务代表对象的访问。
    • 业务服务(Business Service) - 业务服务接口。实现了该业务服务的实体类,提供了实际的业务实现逻辑。
  3. 组合实体模式(Composite Entity Pattern):用在 EJB 持久化机制中。一个组合实体是一个 EJB 实体 bean,代表了对象的图解。当更新一个组合实体时,内部依赖对象 beans 会自动更新,因为它们是由 EJB 实体 bean 管理的。以下是组合实体 bean 的参与者:

    • 组合实体(Composite Entity) - 它是主要的实体 bean。它可以是粗粒的,或者可以包含一个粗粒度对象,用于持续生命周期。
    • 粗粒度对象(Coarse-Grained Object) - 该对象包含依赖对象。它有自己的生命周期,也能管理依赖对象的生命周期。
    • 依赖对象(Dependent Object) - 依赖对象是一个持续生命周期依赖于粗粒度对象的对象。
    • 策略(Strategies) - 策略表示如何实现组合实体。
  4. 数据访问对象模式(Data Access Object Pattern)或 DAO 模式:用于把低级的数据访问 API 或操作从高级的业务服务中分离出来。以下是数据访问对象模式的参与者:

    • 数据访问对象接口(Data Access Object Interface) - 该接口定义了在一个模型对象上要执行的标准操作。
    • 数据访问对象实体类(Data Access Object concrete class) - 该类实现了上述的接口。该类负责从数据源获取数据,数据源可以是数据库,也可以是 xml,或者是其他的存储机制。
    • 模型对象/数值对象(Model Object/Value Object) - 该对象是简单的 POJO,包含了 get/set 方法来存储通过使用 DAO 类检索到的数据。
  5. 前端控制器模式(Front Controller Pattern):是用来提供一个集中的请求处理机制,所有的请求都将由一个单一的处理程序处理。该处理程序可以做认证/授权/记录日志,或者跟踪请求,然后把请求传给相应的处理程序。以下是这种设计模式的实体:

    • 前端控制器(Front Controller) - 处理应用程序所有类型请求的单个处理程序,应用程序可以是基于 web 的应用程序,也可以是基于桌面的应用程序。
    • 调度器(Dispatcher) - 前端控制器可能使用一个调度器对象来调度请求到相应的具体处理程序。
    • 视图(View) - 视图是为请求而创建的对象。
  6. 拦截过滤器模式(Intercepting Filter Pattern):用于对应用程序的请求或响应做一些预处理/后处理。定义过滤器,并在把请求传给实际目标应用程序之前应用在请求上。过滤器可以做认证/授权/记录日志,或者跟踪请求,然后把请求传给相应的处理程序。以下是这种设计模式的实体:

    • 过滤器(Filter) - 过滤器在请求处理程序执行请求之前或之后,执行某些任务。
    • 过滤器链(Filter Chain) - 过滤器链带有多个过滤器,并在 Target 上按照定义的顺序执行这些过滤器。
    • Target - Target 对象是请求处理程序。
    • 过滤管理器(Filter Manager) - 过滤管理器管理过滤器和过滤器链。
    • 客户端(Client) - Client 是向 Target 对象发送请求的对象。
  7. 服务定位器模式(Service Locator Pattern):用在我们想使用 JNDI 查询定位各种服务的时候。考虑到为某个服务查找 JNDI 的代价很高,服务定位器模式充分利用了缓存技术。在首次请求某个服务时,服务定位器在 JNDI 中查找服务,并缓存该服务对象。当再次请求相同的服务时,服务定位器会在它的缓存中查找,这样可以在很大程度上提高应用程序的性能。以下是这种设计模式的实体:

    • 服务(Service) - 实际处理请求的服务。对这种服务的引用可以在 JNDI 服务器中查找到。
    • Context / 初始的 Context - JNDI Context 带有对要查找的服务的引用。
    • 服务定位器(Service Locator) - 服务定位器是通过 JNDI 查找和缓存服务来获取服务的单点接触。
    • 缓存(Cache) - 缓存存储服务的引用,以便复用它们。
    • 客户端(Client) - Client 是通过 ServiceLocator 调用服务的对象。
  8. 传输对象模式(Transfer Object Pattern):用于从客户端向服务器一次性传递带有多个属性的数据。传输对象也被称为数值对象。传输对象是一个具有 getter/setter 方法的简单的 POJO 类,它是可序列化的,所以它可以通过网络传输。它没有任何的行为。服务器端的业务类通常从数据库读取数据,然后填充 POJO,并把它发送到客户端或按值传递它。对于客户端,传输对象是只读的。客户端可以创建自己的传输对象,并把它传递给服务器,以便一次性更新数据库中的数值。以下是这种设计模式的实体:

    • 业务对象(Business Object) - 为传输对象填充数据的业务服务。
    • 传输对象(Transfer Object) - 简单的 POJO,只有设置/获取属性的方法。
    • 客户端(Client) - 客户端可以发送请求或者发送传输对象到业务对象。
  9. 在空对象模式(Null Object Pattern)中,一个空对象取代 NULL 对象实例的检查。Null 对象不是检查空值,而是反应一个不做任何动作的关系。这样的 Null 对象也可以在数据不可用的时候提供默认的行为。

    在空对象模式中,我们创建一个指定各种要执行的操作的抽象类和扩展该类的实体类,还创建一个未对该类做任何实现的空对象类,该空对象类将无缝地使用在需要检查空值的地方。

32、设计模式相关的网站

0、延伸

1、各种生成实例的方法的介绍

在Java中可以使用下面这些方法生成实例。

1、new

一般我们使用Java关键字new生成实例。

可以像下面这样生成Something类的实例并将其保存在obj变量中。

1
Something obj = new Something(); 

这时, 类名(此处的Something)会出现在代码中 。(即形成强耦合关系)

2、clone

我们也可以使用在Prototype模式中学习过的clone方法, 根据现有 的实例复制出一个新的实例。

我们可以像下面这样根据自身来复制出新的实例(不过不会调用构造函数)。

1
2
3
4
5
6
7
8
9
10
11
12
class Something { 
// ...
public Something createClone() {
Something obj = null;
try {
obj = (Something) clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return obj;
}
}

3、new Instance

使用java.lang.Class类的newinstance方法可以通过Class类的实例生成出Class类所表示的类0的实例(会调用无参构造函数)。

下面我们再看一个例子。 假设我们现在已经有了Something类的实例someobj, 通过下面的表达式可以生成另外一个 Something类的实例。

1
someobj.getClass().newinstance() 

实际上, 调用newinstance方法可能会导致抛出InstantiationException异常或是 illegalAccessException异常, 因此需要将其置千try…catch语句块中或是用throws关键字指定调用newinstance方法的方法可能会抛出的异常。

2、类名是束缚吗

话说回来, 在源程序中使用类名到底会有什么问题呢?在代码中出现要使用的类的名字不是理 所当然的吗?

这里, 让我们再回忆一下面向对象编程的目标之一,即“作为组件复用” 。

在代码中出现要使用的类的名字并非总是坏事。 不过 ,—旦在代码中出现要使用的类的名字, 就无法与该类分离开来, 也就无法实现复用

当然 , 可以通过替换源代码或是改变类名来解决这个问题。 但是, 此处说的“作为组件复用”中不包含替换源代码。 以Java来说, 重要的是当手边只有class文件(.class)时, 该类能否被复用。 即使没有Java文件(.java)也能复用该类才是关键。

当多个类必须紧密结合时, 代码中出现这些类的名字是没有问题的。但是如果那些需要被独立 出来作为组件复用的类的名字出现在代码中, 那就有问题了。

3、类的层次与抽象类

父类对子类的要求:

我们在理解类的层次时 , 通常是站在子类的角度进行思考的。也就是说 , 很容易着眼千以 下几点:

  • 在子类中可以使用父类中定义的方法
  • 可以通过在子类中增加方法以实现新的功能
  • 在子类中重写父类的方法可以改变程序的行为

现在 , 让我们稍微改变一下立场 , 站在父类的角度进行思考。在父类中, 我们声明了抽象方法、而将该方法的实现交给了子类。换言之 , 就程序而言,声明抽象方法是希望达到以下目的:

  • 期待子类去实现抽象方法
  • 要求子类去实现抽象方法

也就是说 ,子类具有实现在父类中所声明的抽象方法的责任。因此,这种责任被称为 “子类责任”(subclass responsibility)。

参考链接:

Java设计模式:23种设计模式全面解析

菜鸟驿站-设计模式

廖雪峰-设计模式

面向对象的七大设计原则

设计模式之七大基本原则

happens-before规则相关

详解Java中的clone方法 – 原型模式

java提高篇(五)—–使用序列化实现对象的拷贝

动态代理的几种实现方式及优缺点

有关于Copy-on-write代理:

Copy-on-write + Proxy = ?

相关书籍:

《图解设计模式》

[TOC]

JVM

上篇:内存与垃圾回收

1、JVM与Java体系结构

1、关于Java与JVM

Java:跨平台的语言

第01章_Java语言的跨平台性

JVM:跨语言的平台

第01章_JVM跨语言的平台

2、字节码

随着JDK7.0的正式发布,JVM平台上运行非Java语言编写的程序。

JVM根本不关心运行在其内部的程序到底是使用何种编程语言编写的,它只关心“字节码”文件。也就是说Java虚拟机拥有语言无关性,并不会单纯地与Java语言“终身绑定”,只要其他编程语言的编译结果满足并包含JVM的内部指令集、符号表以及其他的辅助信息,它就是一个有效的字节码文件,就能够被虚拟机所识别并装载运行。

我们平时说的java字节码,指的是用java语言编译成的字节码。准确的说任何能在jvm平台上执行的字节码格式都是一样的。所以应该统称为:jvm字节码

不同的编译器,可以编译出相同的字节码文件,字节码文件也可以在不同的JVM上运行。

Java虚拟机与Java语言并没有必然的联系,它只与特定的二进制文件格式——Class文件格式所关联, Class文件中包含了JVM指令集(或者称为字节码、Bytecodes)和符号表,还有一些其他辅助信息。

3、多语言混合编程

Java平台上的多语言混合编程正成为主流,通过特定领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂的项目需求的一个方向。

例如:在一个项目之中,并行处理用Clojure语言编写,展示层使用JRuby/Rails,中间层则是Java,每个应用层都将使用不同的编程语言来完成,而且,接口对每一层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像使用自己语言的原生API一样方便,因为它们最终都运行在一个虚拟机之上

对这些运行于Java虚拟机之上、Java之外的语言,来自系统级的、底层的支持正在迅速增强,以JSR-292为核心的一系列项目和功能改进(如DaVinci Machine项目、 Nashorn引擎、InvokeDynamic指令、java. lang. invoke包等),推动Java虚拟机从“Java语言的虚拟机”向“多语言虚拟机”的方向发展

4、虚拟机与JVM(java虚拟机)

1、什么是虚拟机

所谓虚拟机(Virtual Machine),就是一台虚拟的计算机,它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机程序虚拟机

  • 大名鼎鼎的Visual Box, VMware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
  • 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虛拟机中执行的指令我们称为Java字节码指令。

但无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。

2、JVM(java虚拟机)

JVM是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。

JVM平台的各种语言可以共享JVM带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。

Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine) ,因为所有的Java程序都运行在JVM内部。

3、JVM的作用

JVM就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。

4、JVM的特点
  • 一次编译,到处运行
  • 自动内存管理
  • 自动垃圾回收功能

带来的好处:

  • 从代码层面:降低了内存泄漏与内存溢出的风险
  • 从程序员层面:让程序员将自己对重心放在业务层面,不用再去手动地进行垃圾回收

带来的坏处:

  • 降低了对程序员对要求,当出现内存方面的问题时不能有效解决。
5、JVM的位置

第01章_JVM所处位置

JVM是运行在操作系统之上的,它与硬件没有直接的交互。

JVM模拟的是系统,在不同系统之上,构建了一个统一的系统平台。所以在安装JDK的时候要关注JDK是安装在哪个操作系统上,因为不同的操作系统上安装的JVM是不同的。

JDK的构成:下图来自JDK8官网。

image-20210420004314261

java程序想要正确运行需要经历两个过程:

  • java文件 –(编译)–> class字节码文件:使用的编译器为:前端编译器。典型:javac
  • class字节码文件–(解释)–> 二进制文件:运行。解释会用到:Java SE API 还有后端编译器(将class字节码文件编译为二进制文件)(后端编译器在JVM当中)

5、JVM的整体结构

HotSpotVM是目前市面上高性能虚拟机的代表作之一。

它采用解释器与即时编译器并存的架构

在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C+ +程序一较高下的地步。

JVM的架构简图:程序的解释运行图

第02章_JVM架构-简图

其中将其分成三层:

  1. 上层:class字节码文件进入类装载器子系统(Class loader),将class字节码文件加载到内存当中,生成一个大的class对象。这个过程中会涉及到:

    1. 加载
    2. 链接(分成三步)
    3. 初始化
  2. 中层:

    • 方法区和栈是多线程共享
    • (Java栈(本地方法栈),本地方法栈,程序计数器是每个线程独有一份
  3. 下层:把字节码文件加载到内存以后,就可以进行解释运行了。执行引擎(Execution Engine),有三部分内容:

    1. 解释器(Interpreter):负责字节码文件的解释运行主要保证程序执行的响应时间

    2. 及时编译器(JIT Compiler):对于反复运行的热点代码进行提前的编译缓存。及时编译器又叫做后端编译器,用来将字节码文件字节码指令编译成操作系统能读懂的机器指令。(高级语言->机器语言)主要负责程序的执行性能

    3. 垃圾回收器(Garbage Collection,简称GC):实现垃圾的自动回收

      image-20210420011524768

6、Java代码的执行流程

image-20210420013453679

7、JVM的架构模型

Java编译器输入的指令流基本上分为两种:

  • 是一种基于栈的指令集架构
  • 另外一种指令集架构则是基于寄存器的指令集架构

两种架构的区别:

  • 基于栈式架构的特点:
    • 设计和实现更简单,适用于资源受限的系统
    • 避开了寄存器的分配难题:使用零地址指令方式分配;
    • 指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现。
    • 不需要硬件支持,可移植性更好,更好实现跨平台
  • 基于寄存器架构的特点
    • 典型的应用是x86的二进制指令集:比如传统的PC以及Android的Davlik虛拟机;
    • 指令集架构则完全依赖硬件,可移植性差
    • 性能优秀执行更高效;
    • 花费更少的指令去完成一项操作
    • 在大部分情况下,基于寄存器架构的指令集往往都以一地址指令、二地址指令和三地址指令为主,而基于栈式架构的指令集却是以零地址指令为主。

总结:

  • 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
    • 优点是跨平台, 指令集小,编译器容易实现
    • 缺点是性能下降,实现同样的功能需要更多的指令
  • 时至今日,尽管嵌入式平台已经不是Java程序的主流运行平台了(准确来说应该是HotSpotVM的宿主环境已经不局限于嵌入式平台了),那么为什么不将架构更换为基于寄存器的架构呢?
    1. 基于栈式的架构在设计与实现上比基于寄存器架构的设计要简单
    2. 基于栈式的架构在非资源受限的场景当中也是可以使用
    3. 主要还是因为栈式架构可以实现跨平台,而基于寄存器架构由于与硬件的耦合度太高,不能实现跨平台。

8、JVM的生命周期

1、虚拟机的启动

Java虛拟机的启动是通过引导类加载器(bootstrap class loader) 创建一个初始类(initial class) 来完成的,这个类是由虚拟机的具体实现指定的。

程序的执行:主方法(main)所在类加载到内存当中。而自定义的类的加载是通过系统类加载器(应用类加载器)实现的。由于父类的加载要早于子类,这就导致了java虚拟机的启动,创建一个初始类(initial class) ,然后调用初始类(initial class)当中的main方法,在这main方法当中使用其他的一些类来相继地加载后继的所有类。

类加载器分成:

  • 引导类加载器(负责超类的加载(如Object))
  • 扩展类加载器
  • 系统类加载器(负责自定义类的类加载)
  • 启动类加载器
  • 用户自定义的类加载器
2、虚拟机的执行
  • 一个运行中的Java虚拟机有着一个清晰的任务:执行Java程序
  • 程序开始执行时他才运行程序结束时他就停止
  • 执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虛拟机的进程
3、虚拟机的退出

有如下的几种情况:

  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虛拟机进程终止
  • 某线程调用Runtime类或System类的exit方法,或Runtime类的halt方法,并且Java安全管理器也允许这次exit或halt操作
  • 除此之外,JNI ( Java Native Interface) 规范描述了用JNI Invocation API来加载或卸载Java虚拟机时,Java虚拟机的退出情况。

9、JVM的发展历程

1、Sun Classic VM(SUN)

Sun公司发布的世界上第一款商用Java虚拟机,在JDK1.4时被完全淘汰。

这款虚拟机内部只提供解释器

如果使用JIT编译器,就需要进行外挂。但是一旦使用了JIT编译器,JIT就会接管虚拟机的执行系统。解释器就不再工作。解释器和编译器不能配合工作。(两者只能存一

  • 只使用解释器:当代码中重复的代码多(如循环等等)的时候执行效率低
  • 只使用JIT编译器:由于将字节码文件当中字节码指令编译成机器指令进行缓存也是需要时间的。这就导致了程序启动时间过长,加上占用的缓存空间有限。

现在hotspot内置了此虚拟机

2、Exact VM(SUN)

为了解决上一个虚拟机问题,JDK1.2时, sun提供了此虚拟机。

Exact Memory Management:准确式内存管理

  • 也可以叫Non-Conservative/ Accurate Memory Management
  • 虚拟机可以知道内存中某个位置的数据具体是什么类型。

具备现代高性能虚拟机的雏形

  • 热点探测
  • 编译器与解释器混合工作模式

只在Solaris平台短暂使用,其他平台上还是classic vm。最终被Hotspot虚拟机替代

3、Hotspot虚拟机(三大虚拟机之一)(Longview Technologies\SUN\Oracle)

JDK1.3时,HotSpot VM成为默认虚拟机

目前Hotspot占有绝对的市场地位

  • 不管是现在仍在广泛使用的JDK6,还是使用比例较多的JDK8中,默认的虚拟机都是
    HotSpot
  • Sun/Oracle JDK和OpenJDK的默认虚拟机
  • 因此本课程中默认介绍的虛拟机都是HotSpot,相关机制也主要是指HotSpot的GC机
    。(比如其他两个商用虚拟机都没有方法区的概念)

从服务器、桌面到移动端、嵌入式都有应用。

名称中的HotSpot指的就是它的热点代码探测技术。

  • 通过计数器找到最具编译价值代码,触发即时编译或栈上替换
  • 通过编译器与解释器协同工作在最优化的程序响应时间与最佳执行性能中取得平衡
4、JRockit(三大虚拟机之一)(BEA\Oracle)

专注于服务器端应用

  • 它可以不太关注程序启动速度,因此JRockit内部不包含解析器实现,全部代码都靠即时编译器编译后执行。

大量的行业基准测试显示,JRockit JVM是 世界上最快的JVM

  • 使用JRockit产品,客户已经体验到了显著的性能提高(一些超过了70号)和硬件成本的减少(达50号)

优势:全面的Java运行时解决方案组合

  • JRockit面向延迟敏感型应用的解决方案:JRockit Real Time提供以毫秒或微秒级的JVM响应时间,适合财务、军事指挥、电信网络的需要
  • MissionControl服务套件:它是一组以极低的开销来监控、管理和分析生产
    环境中的应用程序的工具。
    • JDK Mission Control(JMC)(Oracle公司整合)(主要是用来监控内存泄漏)
      • 内存泄漏监测器
      • JVM运行时分析器
      • 管理的控制台

2008年,BEA被Oracle收购。

Oracle表达了整合两大优秀虚拟机的工作,大致在JDK 8中完成。整合的方式是在HotSpot的基础上,移植JRockit的优秀特性。

5、J9(三大虚拟机之一)(IBM)

全称: IBM Technology for Java Virtual Machine, 简称IT4J,内部代号: J9

市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM,广泛用于IBM的各种Java产品。

目前,有影响力的三大商用服务器之一,也号称是世界上最快的Java虚拟机(在使用自己家产品时)。

2017年左右,IBM发布了开源J9 VM,命名为openJ9,交给Eclipse基金会管理,也称为Ecilpse OpenJ9

6、KVM和CDC/ CLDC Hotspot

Oracle在Java ME产品线上的两款虚拟机为: CDC/CLDC HotSpot Implementation VM

KVM (Kilobyte)是CLDC- HI早期产品

目前移动领域地位尴尬,智能手机被Android和iOS二分天下。

KVM简单、轻量、高度可移植,而向更低端的设备上还维持自己的一片市场

  • 智能控制器、传感器
  • 老人手机、经济欠发达地区的功能手机

所有的虚拟机的原则:一次编译,到处运行

7、Azul VM(Azul Systems)

前而三大“高性能Java虚拟机”使用在通用硬件平台

这里Azul VM和BEA Liquid VM是与特定硬件平台绑定、软硬件配合的专有虚拟机(高性能Java虚拟机中的战斗机)
Azul VM是Azul Systems 公司在HotSpot基础上进行大量改进,运行于Azul Systems 公司的专有硬件Vega系统上的Java虚拟机。

每个Azul VM实例都可以管理至少数十个CPU和数百GB内存的硬件资源,并提供在巨大内存范围内实现可控的GC时间的垃圾收集器、专有硬件优化的线
程调度等优秀特性。

2010年,Azul Systems 公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性。

8、Liquid VM(BEA)

高性能Java虚拟机中的战斗机

BEA公司开发的,直接运行在自家Hypervisor系统上

Liquid VM即是现在的JRockit VM(Virtual Edition),Liquid VM不需要成操作系统的支持,或者说它自己本身实现了一个专用操作系统的必要功能,如线程调度、文件系统、网络支持等

随着JRockit虚拟机终止开发,Liquid VM项目也停止了。

9、Apache Harmony(IBM和Inter)

Apache Harmony是IBM和Inter联合开发的开源JVM,受到同样开源的OpenJDK的压制。

虽然目前并没有Apache Harmony被大规模商用的案例,但是它的Java类库代码吸纳进了Android SDK。

10、Microsoft JVM(Microsoft)

微软为了在IE3浏览器中支持Java Applets,开发了Microsoft JVM。

只能在Window平台下运行。但确实是当时Windows下性能最好的Java VM。

1997年,Sun以侵犯商标、不正当竞争罪名指控微软成功,赔了sun很多钱。微软在windowsXP SP3中抹掉了其VM。现在windows上安装的jdk都是HotSpot。

11、TaobaoJVM(Alibaba)

Alibaba基于OpenJDK开发了自己的定制版本AlibabaJDK,简称AJDK。是整个阿里Java体系的基石。

基于OpenJDK HotSpot VM发布的国内第一个优化、深度定制且开源的高性能服务器版Java虚拟机

  • 创新GCIH (GC invisible heap)技术实现了off-heap,即将生命周期较长的java对象从heap中移到heap之外,并且GC不能管理GCIH内部的java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
  • GCIH中的对象还能够在多个java虚拟机进程中实现共享
  • 使用crc32指令顺序JVM intrinsic降低JNI 的调用开销
  • PMU hardware的java profiling tool 的诊断协助功能
  • 针对大数据场景的ZenGC

taobao NM应用在阿里产品上性能高,硬件严重依赖Intel的CPU,损失了兼容性,但提高 了性能

目前已经在淘宝、天猫上线,把Oracle官方版本全部替换了。

12、Dalvik VM(Google)

谷歌开发的,应用与Android系统,并在Android2.2中提供了JIT,发展迅猛。

Dalvik VM只能称作虚拟机,而不能称作”Java 虚拟机”,它没有遵循Java虚拟机规范

不能执行Java的Class文件

基于寄存器架构,不是jvm的栈架构

执行的是编译以后的dex(Dalvik Executable)文件。执行效率比较高。

  • 它执行的dex(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写的应用程序,可以直接使用大部分的Java API等。

Android5.0使用支持提前编译(Ahead of Time Compila,AOT)的ART VM替换了Dalvik VM

13、Graal VM

2018年4月,Oracle Labs公开了Graal VM,号称”Run Programs Faster Anywhere“。与1995年java的”write once,run anywhere”遥相呼应。

Graal VM在HotSpot VM基础上增强而成的跨语言全栈虚拟机可以作为”任何语言”的运行平台使用。语言包括:Java、Scala、Groovy、Kotlin;C、C++、JavaScript、Ruby、Python、R等。

支持不同语言中混合对方的接口和对象,支持这些语言使用已经编写好的本地库文件

工作原理:将这些语言的源代码或源代码编译后的中间格式,通过解释器转换成能被Graal VM接收的中间表示。Graal VM通过Truffle工具快速构建面向一种新语言的解释器。在运行时还能进行即时编译优化,获得比原生编译器更优秀的执行效率。

如果说HotSpot有一天真的被取代,Graal VM希望最大。但是Java的软件生态没有丝毫变化。

14、其他虚拟机
  • Java Card VM
  • Squawk VM
  • JavaInJava
  • Maxine VM
  • Jikes RVM
  • IKVM.NET
  • Jam VM
  • Cacao VM
  • Sable VM
  • Kaffe
  • Jelatine JVM
  • Nano VM
  • MRP
  • Moxie JVM

2、类加载器子系统(Class Loader)

1、内部结构概述

1、类加载器子系统作用
  • 类加载器子系统负责从文件系统或者网络中加载Class文件,class文件在文件开头有特定的文件标识(CA FE BA BE,是一个魔数(Coffee baby))。
  • ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
  • 加载的类信息存放与一块称为方法区的内存空间。除了类信息以外,方法区还会存放运行时常量池信息,可能还包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)常量池在运行过程中加载到内存里,叫运行时常量池。
2、类加载器ClassLoader角色

image-20210420231839277

  1. class file 存在于本地硬盘上,可以理解为设计师画在纸上的模板,而最终这个模板在执行的时候是要加载到JVM当中来,根据这个文件实例化出n个一模一样的实例。
  2. class file 加载到JVM中,被称为DNA元数据模板,放在方法区。
  3. 在.class文件-> JVM ->最终成为元数据模板,此过程就要一个运输工具(类装载器Class Loader) ,扮演一个快递员的角色。

2、类加载器与类的加载过程

1、JVM架构

JVM架构-简图:
第02章_JVM架构-简图

JVM架构-详细图解(中英文):

image-20210420161504521

2、类加载的过程

类加载的过程:

image-20210420232214018

程序加载过程:

image-20210420232257344

3、类加载的三个阶段
1、阶段一:Loading(加载)

加载:

  1. 通过一个类的全限定类名获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java. lang.Class对象, 作为方法区这个类的各种数据的访问入口。

补充:加载.class文件的方式:

  • 本地系统中直接加载
  • 通过网络获取,典型场景: Web Applet
  • zip压缩包中读取,成为日后jar、war格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术(java.lang.reflect.proxy)
  • 其他文件生成,典型场景:JSP应用
  • 专有数据库中提取.class文件,比较少见
  • 加密文件中获取,典型的防Class文件被反编译的保护措施
2、阶段二:Linking(链接)

验证(Verify) :

  • 目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求保证被加载类的正确性,不会危害虚拟机自身安全。

  • 主要包括四种验证

    • 文件格式验证
    • 元数据验证
    • 字节码验证
    • 符号引用验证
  • 例如:class文件在文件开头有特定的文件标识(CA FE BA BE,是一个魔数(Coffee baby))

    image-20210420234046557

准备(Prepare) :

  • 类变量分配内存并且设置该类变量的默认初始值,即零值。
  • 这里不包含用final修饰的static(即:常量),因为final在编译的时候就会分配了,准备阶段会显式初始化。
  • 这里不会为实例变量分配初始化类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

例如:

1
2
//prepare: a = 0 ---> Initialization : a = 1
private static int a = 1;

其中数据类型不同,默认初始值也就不同:

  • 整型(byte\short\int\long):0
  • 浮点型(float\double):0.0f
  • 字符型(char):\u0000
  • 布尔型(boolean):false
  • 引用类型:null

解析(Resolve) :

  • 常量池内的符号引用转换为直接引用的过程。
  • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行。
  • 符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《java虚拟机规范》的Class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄
  • 解析动作主要针对接口字段类方法接口方法方法类型等。对应常量池中的CONSTANT_ Class_ info、CONSTANT Fieldref_ info、 CONSTANT Methodref_ info等。
3、阶段三:Initialization(初始化)

初始化:

  • 初始化阶段就是执行类构造器方法()的过程

  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作静态代码块中的语句合并而来。

    当不涉及到类变量的赋值动作与有关静态static(包括静态代码快、静态构造器,静态变量等等)的动作时,类构造器方法()不会创建。

    1
    2
    3
    4
    5
    6
    7
    public class ClinitTest {
    //任何一个类声明以后,内部至少存在一个类的构造器<init>
    private int a = 1;
    public static void main(String[] args) {
    int b = 2;
    }
    }

    image-20210421005041972

  • 构造器方法中指令按语句在源文件中出现的顺序执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class ClassInitTest {
    private static int num = 1;
    static{
    num = 2;
    // 赋值
    number = 20;
    System.out.println(num);
    //System.out.println(number);//报错:非法的前向引用。
    }
    // 声明
    private static int number = 10; //linking之prepare: number = 0 --> initial: 20 --> 10
    public static void main(String[] args) {
    System.out.println(ClassInitTest.num);//2
    System.out.println(ClassInitTest.number);//10
    }
    }
  • ()不同于类的构造器。(关联: 构造器是虚拟机视角下的 ())

    任何一个类声明以后,内部至少存在一个类的构造器(可以是自己声明的,也可以说系统默认提供的)

    image-20210421005632840

  • 若该类具有父类,JVM会保证子类的 ()执行前,父类的()已经执行完毕

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public class ClinitTest1 {
    static class Father{
    public static int A = 1;
    static{
    A = 2;
    }
    }
    static class Son extends Father{
    public static int B = A;
    }
    public static void main(String[] args) {
    //加载Father类,其次加载Son类。
    System.out.println(Son.B);//2
    }
    }
  • 虚拟机必须保证一个类的()方法在多线程下被同步加锁

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class DeadThreadTest {
    public static void main(String[] args) {
    Runnable r = () -> {
    System.out.println(Thread.currentThread().getName() + "开始");
    DeadThread dead = new DeadThread();
    System.out.println(Thread.currentThread().getName() + "结束");
    };
    Thread t1 = new Thread(r,"线程1");
    Thread t2 = new Thread(r,"线程2");
    t1.start();
    t2.start();
    }
    }
    class DeadThread{
    static{
    if(true){
    // 若一个类的<clinit>()方法在多线程下被同步加锁
    // 那么这里的打印代码就只会执行一次
    System.out.println(Thread.currentThread().getName() + "初始化当前类");
    while(true){

    }
    }
    }
    }

    执行结果:

    线程2开始
    线程1开始
    线程2初始化当前类

    一个类只需要往内存中加载一次就可以了,加载之后将其放在方法区(方法区在JDK7之前被称为永久代,JDK7之后被称为源空间),源空间其实使用的是本地内存,即类加载到内存之后是使用直接内存进行缓存。若以后使用到该类,那么使用的都是内存中已经存在的类本身。所以,虚拟机在加载类的时候只会调用一次方法

3、类加载器分类

1、类加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器( Bootstrap ClassLoader)**和自定义类加载器(User-Defined ClassLoader)** 。

  • 引导类加载器( Bootstrap ClassLoader):

    • 本身不是使用java语言编写,而是使用C与C++进行编写
  • 自定义类加载器(User-Defined ClassLoader)

    • 使用java语言编写

    • 派生于抽象类ClassLoader。所以扩展类加载器(Extinction Class Loader)与系统类加载器(System Class Loader)都属于自定义类加载器

      其中sun.misc.Launcher它是一个java虚拟机的入口应用

      image-20210421012614177

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器

无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:

image-20210421011954294

这里的四者之间的关系是包含关系。不是上层下层,也不是子父类的继承关系。

对于引导类加载器(Bootstrap Class Loader)、扩展类加载器(Extension Class Loader)与系统类加载器(System Class Loader)三者的关系:

  • 系统类加载器(System Class Loader)的上层就是扩展类加载器(Extension Class Loader):对于用户自定义类来说:默认使用系统类加载器进行加载
  • 扩展类加载器(Extension Class Loader)的上层是引导类加载器(Bootstrap Class Loader)
  • 引导类加载器(Bootstrap Class Loader)是最高层的类加载器:Java的核心类库都是使用引导类加载器进行加载的。并且我们获取不到引导类加载器。因为引导类加载器并不是所以java语言进行编写的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ClassLoaderTest {
public static void main(String[] args) {
//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null
}
}
2、启动类加载器(引导类加载器,Bootstrap ClassLoader )(虚拟机自带的加载器)
  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部,是JVM的一部分。
  • 它用来加载Java的核心库(JAVA HOME/jre/lib/rt.jar、resources. jar或sun.boot.class.path路径下的内容) , 用于提供JVM自身需要的类
  • 不继承自java. lang .ClassLoader,没有父加载器
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
3、扩展类加载器(Extension ClassLoader)(虚拟机自带的加载器)
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现

  • 派生于ClassLoader类

  • 父类加载器为启动类加载器

  • 从java. ext. dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录(扩展目录)下加载类库。

    如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

  • JDK9以后扩展类加载器改为平台类加载器

4、应用程序类加载器(系统类加载器,AppClassLoader)(虚拟机自带的加载器)
  • java语言编写,由sun.misc.Launcher$AppClassLoader实现
  • 派生于ClassLoader类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载
  • 通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器
5、用户自定义类加载器
  • 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,来定制类的加载方式。

  • 为什么要自定义类加载器:

    • 隔离加载类

      在某些框架中需要使用中间件,然而中间件与应用模块是隔离的。所以需要把类加载到不同的环境当中,确保应用当中引用到的框架的jar包与中间件的jar包是不冲突的(冲突:框架与中间件的某些类的类名一样,路径也相同)。

      所以需要做一个类的仲裁。一般主流的容器类框架都会自定义类加载器,让本身与不同中间件之间是隔离的,避免类的冲突。

    • 修改类加载的方式

      除了引导类加载器,其他类加载器都可以在需要的时候进行动态加载

    • 扩展加载源

      可以从数据库当中,或者电视机的机饼盒等等加载字节码文件的来源

    • 防止源码泄漏

      对字节码文件进行加密,防止被反编译篡改。

      加密之后运行代码时就需要进行解密,这时候就可以通过自定义加载器的方式进行解密

  • 用户自定义类加载器实现步骤:

    1. 开发人员可以通过继承抽象类java.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求;

    2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass()方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中。(findClass()方法与defineClass()方法配合使用)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      public class CustomClassLoader extends ClassLoader {
      @Override
      protected Class<?> findClass(String name) throws ClassNotFoundException {
      try {
      byte[] result = getClassFromCustomPath(name);
      if(result == null){
      throw new FileNotFoundException();
      }else{
      return defineClass(name,result,0,result.length);
      }
      } catch (FileNotFoundException e) {
      e.printStackTrace();
      }
      throw new ClassNotFoundException(name);
      }
      private byte[] getClassFromCustomPath(String name){
      //从自定义路径中加载指定类:细节略
      //如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
      return null;
      }
      public static void main(String[] args) {
      CustomClassLoader customClassLoader = new CustomClassLoader();
      try {
      Class<?> clazz = Class.forName("One",true,customClassLoader);
      Object obj = clazz.newInstance();
      System.out.println(obj.getClass().getClassLoader());
      } catch (Exception e) {
      e.printStackTrace();
      }
      }
      }
    3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

4、Class Loader的使用说明

ClassLoader类,它是一个抽象类,其后所有的类加载器都继承自ClassLoader (不包括启动类加载器)

相关方法与描述:

方法名称 方法名称
getParent() 返回该类加载器的超类加载器
loadClass(String name) 加载名称为name的类,返回结果为java.lang.Class类的实例
findClass(String name) 查找名称为name的类,返回结果为java lang Class类的实例
findLoadedClass(String name) 查找名称为name的已经被加载过的类,返回结果为java lang Class类的实例
defineClass(String name, byte[] b, int off, int len) 把字节数组b中的内容转换为一个Java类,返回结果为java.lang.Class类的实例
resolveClass(Class<?> c) 连接指定的一个Java类

获取ClassLoader的途径:

  1. 获取当前类的ClassLoader

    1
    clazz.getClassLoader()
  2. 获取当前线程上下文的ClassLoader

    1
    Thread.currentThread().getContextClassLoader()
  3. 获取系统的ClassLoader

    1
    ClassLoader.getSystemClassLoader()
  4. 获取调用者的ClassLoader

    1
    DriverManager.getCallerClassLoader()

5、双亲委派机制

1、什么是双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式。

2、工作原理
  1. 如果一个类加载器收到类加载请求,它并不会自己去加载,而是把这个请求委托给父类的加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;
  3. 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

image-20210421103744316

3、例子

image-20210421105124634

  • SPI接口是由引导类加载器加载的
  • 具体接口的实现类由于使用了第三方jdbc.jar,所以是由线程上下文类加载器加载的,而线程上下文类加载器的默认就是系统类加载器。(反向委派)
4、优势
  • 避免类的重复加载

  • 保护程序安全,防止核心API被随意篡改

    • 自定义类:java.lang.String
    • 自定义类:java.lang,ShkStart

在src目录下新建java.lang.String:

1
2
3
4
5
6
7
8
9
10
11
package java.lang;

public class String {
static{
System.out.println("我是自定义的String类的静态代码块");
}
//错误: 在类 java.lang.String 中找不到 main 方法
public static void main(String[] args) {
System.out.println("hello,String");
}
}

运行结果:

在类 java.lang.String 中找不到 main 方法

在src目录下新建java.lang并在该包下编写自定义的类:

1
2
3
4
5
public class ShkStart {
public static void main(String[] args) {
System.out.println("hello!");
}
}

运行结果:java.lang.SecurityException:Prohibited package name:java.lang

沙箱安全机制

自定义String类,但是在加载自定义String类的时候会率先随意引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar包中java\lang\String.class),报错信息说没有main方法就是因为加载的是rt.jar包中的String类。这样可以保证对java核心源代码的保护,这就是沙箱安全机制

6、其他

1、在JVM中表示两个class对象是否为同一个类存在两个必要条件
  • 类的完整类名必须一致,包括包名
  • 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

换句话说:在JVM中,既使这两个类对象(class对象)来源于同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的。

2、对类加载器的引用

JVM必须知道一个类型是由启动类加载器加载的还是有用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的类加载器是相同的。

3、类的主动使用与被动使用

Java程序对类的使用方式分为:主动使用和被动使用

  • 主动使用,又分为七种情况:

    • 创建类的实例

    • 访问某个类或接口的静态变量,或者对该静态变量赋值

    • 调用类的静态方法

    • 反射(比如:Class.forName(“com.atguigu.Test”))

    • 初始化一个类的子类

    • Java虚拟机启动时被标明为启动类的类

    • JDK7开始通过动态语言支持:

      java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化,则初始化

  • 被动使用:除了上述七种情况,其他使用Java类的方式都被看作是对类的被动使用,都不会导致类的初始化


3、运行时数据区概述及线程

1、概述

1、经典的JVM内存布局

内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。

不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来探讨一下经典的JVM内存布局:

image-20210421112837894

其中:方法区在JVM规范中是一个逻辑概念,由虚拟机自己进行具体实现。

  • HotSpot7和以前的版本用的是堆上的永久代实现方法区
  • HotSpot8之后使用元数据区实现方法区
  • 常量池在jdk8以后也被放到了堆中
2、进程同步与线程同步:

Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁(进程同步)**。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁**。

灰色的为单独线程私有的,红色的为多个线程共享的。即:

  • 每个线程:独立包括程序计数器、栈、本地栈。
  • 每个进程:线程间共享,堆、堆外内存(永久代或元空间、代码缓存)(问题:怎么保证线程安全)

image-20210421113146594

第03章_线程共享和私有的结构

​ 其中:(一个线程一份)

  • PC:程序计数器
  • VMS:虚拟机栈
  • NMS:本地方法栈
3、关于线程间共享的说明:

image-20210421114556021

每个JVM只有一个Runtime实例。即为运行时环境,相当于内存结构的中间的那个框框(运行时数据区(Runtime Data Area)):运行时环境。

2、线程

1、关于线程

线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行的执行。

在Hotspot JVM里,每个线程都与操作系统的本地线程直接映射

  • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也同时创建。Java线程执行终止后,本地线程也会回收。

操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

2、线程的分类
  • 普通线程
  • 守护线程
3、JVM的系统线程

如果你使用jconsole或者是任何一个调试工具,都能看到在后台有许多线程在运行。

这些后台线程不包括调用public static void main (String[])的main线程以及所有这个main线程自己创建的线程

这些主要的后台系统线程在HotSpot JVM里主要是以下几个:

  • 虚拟机线程

    这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括**”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销**。

  • 周期任务线程

    这种线程是时间周期事件的体现(比如中断)**,他们一般用于周期性操作的调度执行**。

  • GC线程

    这种线程对在JVM里不同种类的垃圾收集行为提供了支持

  • 编译线程

    这种线程在运行时会将字节码编译成到本地代码

  • 信号调度线程

    这种线程接收信号并发送给JVM在它内部通过调用适当的方法进行处理


4、程序计数器(PC寄存器)

1、PC Register介绍

image-20210422000432697

JVM中的程序计数寄存器(Program Counter Register) 中,Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行

这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟

作用

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令

image-20210421201053065

  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域
  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的生命周期与线程的生命周期保持一致
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 不存在垃圾回收问题。
  • 它是唯一一个在Java虚拟机规范中没有规定任何OutOtMemoryError情况的区域
  • 即:无GC,无OOM

2、举例说明

image-20210422001738599

image-20210422001753431

针对5进行举例:(PC寄存器的意义或者作用)

  1. 指令地址5就是PC寄存器里面存放的值
  2. 执行引擎会在PC寄存器里面获取指令地址对应的操作指令(istore_2)
  3. 执行引擎得到操作指令后会执行下面两个操作:
    1. 操作虚拟机栈(如局部变量表、操作数栈等等),实现数据的存取操作以及一些求和运算等等。
    2. 把字节码指令翻译为机器指令
  4. 机器指令可以让对应的CPU做运算

3、两个常见问题

  1. 使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

    因为CPU需要不停的切换各个进程,这时候切换回来以后,就得知道从哪开始继续执行。

    JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

    第04章_PC寄存器

  2. PC寄存器为什么会被设定为线程私有?

    我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?

    为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况

    由于CPU时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令

    这样必然导致经常中断或恢复,如何保证分毫无差呢?

    每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响

4、CPU时间片

CPU时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片

宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行

但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平?一种方法就是引入时间片,每个程序轮流执行

并行与并发:

  • 并行就是两个核同时算
  • 并发就是一个核算两个一人一段。

image-20210421202826450


5、虚拟机栈

1、虚拟机栈概述

1、虚拟机栈出现的背景

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。

优点

  • 跨平台
  • 指令集小
  • 编译器容易实现

缺点

  • 性能下降
  • 实现同样的功能需要更多的指令
2、内存中的栈与堆

栈:

  • 栈是运行时的单位
  • 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据

堆:

  • 堆是存储的单位
  • 堆解决的是数据存储的问题,即数据怎么放、放在哪里。

image-20210422005102514

3、虚拟机栈基本内容

Java虚拟机栈是什么?

Java虚拟机栈(Java Virtual Machine Stack) ,早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame) (栈存储数据的基本单位),对应着一次次的Java方法调用

  • 是线程私有的

image-20210422012357234

Java虚拟机栈生命周期:

  • 生命周期和线程一致

Java虚拟机栈作用:

  • 主管Java程序的运行,它保存方法的**局部变量(8种基本数据类型、对象的引用)**、部分结果,并参与方法的调用和返回。

栈的特点(优点):

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

  • JVM直接对Java栈的操作只有两个:

    • 每个方法执行,伴随着进栈(入栈、压栈)
    • 执行结束后的出栈工作
  • 对于栈来说不存在垃圾回收问题,但是存在内存溢出的情况。

  • 即:无GC,有OOM

    image-20210422013552022

栈中可能存在的异常:

Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。

  • 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError异常

    image-20210422013326303

  • 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个OutOfMemoryError异常

设置栈内存大小:

我们可以使用参数-Xss选项来设置线程的最大栈空间,**栈的大小直接决定了函数调用的最大可达深度**。

image-20210422014055436

设置步骤:

  1. 在IDEA点开Run

    image-20210422014347776

  2. 在Run下面有选项Edit Configurations...

    image-20210422014445720

  3. 在当前类下的VM options中进行参数设置。(参数参考上-Xss的设置)程序调优的一种方案:参数调优

    image-20210422014706947

参数设置后的测试方法:

image-20210422013442292

2、栈的存储单位

1、栈中存储什么?
  • 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)为基本存储单位的格式存在。
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)
  • 栈帧是一个内存区块,是一个数据集维系着方法执行过程中的各种数据信息
2、栈的运行原理
  • JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈遵循“先进后出”/“后进先出”原则

  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈项栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame) ,与当前栈帧相对应的方法就是当前方法(Current Method)**,定义这个方法的类就是当前类(Current Class)** 。

  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作

  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。

    第05章_方法与栈桢

  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧

  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

  • Java方法有两种返回函数的方式:

    • 正常的函数返回,使用return指令
    • 抛出异常

    不管使用哪种方式,都会导致栈帧被弹出

3、栈帧的内部结构

每个栈帧中存储着:

  • 局部变量表(Local Variables:LV)
  • 操作数栈(Operand Stack) (或表达式栈)
  • 动态链接(Dynamic Linking) ( 或指向运行时常量池的方法引用)
  • 方法返回地址(Return Address) (或方法正常退出或者异常退出的定义)
  • 一些附加信息

第05章_栈桢内部结构

image-20210422173049395

3、局部变量表( Local Variables)

1、局部变量表的概述
  • 局部变量表也被称之为局部变量数组本地变量表
  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型对象引用(reference) ,以及
    return Address类型。(因为各类数据类型都可以通过数字来表示)
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。 在方法运行期间是不会改变局部变量表的大小的。
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
2、对程序编译后的字节码文件的查看方法

程序代码:(以main方法为例,其他方法类似)

1
2
3
4
5
6
7
8
public class LocalVariablesTest {
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
public void test1() {/* ... */}
}

image-20210422180804881

image-20210422181213595

image-20210422181755767

image-20210422182334511

image-20210422193112649

image-20210422193542032

3、关于Slot的理解
  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

  • 局部变量表的最基本的存储单元是Slot (变量槽)

  • 局部变量:表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个slot (包括returnAddress类型),64位的类型(long和double)占用两个slot

    • byte、short 、char、在存储前被转换为int,boolean 也被转换为int,0表示false,非0表示true,float、引用数据类型的引用
    • long和double则占据两个Slot。
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的基本变量值

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上

  • 如果需要访问局部变量表中一个64bit的基本变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)

    image-20210422200200175

  • 如果当前帧是由构造方法或者实例方法创建的,那么对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。

  • 在静态(static)方法中不能引用this:因为this变量不存在于静态方法的局部变量表中!!

    image-20210422201450476

4、Slot的重复利用

栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的

变量c使用之前已经销毁的变量b占据的slot的位置:

image-20210422202145932

5、静态变量与局部变量的对比

变量的分类

  • 按照数据类型分:
    • 基本数据类型
    • 引用数据类型
  • 按照在类中声明的位置分:
    • 成员变量:在使用前,都经历过默认初始化赋值
      • 类变量(static修饰): linking的prepare阶段:给类变量默认赋值 —> initial阶段:给类变量显式赋值即静态代码块赋值
      • 实例变量(没有static修饰):随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
    • 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过。

静态变量(类变量)与局部变量对比

  • 参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配

  • 我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值

  • 和类变量初始化不同的是,局部变量表**不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用

    1
    2
    3
    4
    5
    public void test(){
    int i;
    // 报错:没有赋值不能够使用。
    System.out.println(i).
    }
6、补充说明:
  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
  • 局部变量表中的变量也是重要的垃圾回收根节点(根搜索算法\可达性分析),只要被局部变量表中直接或间接引用的对象都不会被回收

4、操作数栈(Operand Stack)

  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last- In-First-Out:LIFO)的操作数栈,也可以称之为表达式栈(Expression Stack)

  • 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push) /出栈(pop)。

    • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。

    • 比如:执行复制、交换、求和等操作

      image-20210422204325546

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

  • 操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的

  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,在方法运行期间是不会改变操作数栈的大小的。保存在方法的Code属性中,为max_ stack的值。

  • 栈中的任何一个元素都是可以任意的Java数据类型。

    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈单位深度
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop) 操作来完成一次数据访问。

  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

    image-20210422211053093

  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈

5、代码跟踪

1、对操作数栈相关知识点的代码分析

image-20210422210316458

image-20210422210545629

image-20210422210620080

image-20210422210641231

image-20210422210655579

2、面试问题:i++ VS ++i
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public void add(){
//第1类问题:
int i1 = 10;
i1++;

int i2 = 10;
++i2;

//第2类问题:
int i3 = 10;
int i4 = i3++;

int i5 = 10;
int i6 = ++i5;

//第3类问题:
int i7 = 10;
i7 = i7++;

int i8 = 10;
i8 = ++i8;

//第4类问题:
int i9 = 10;
int i10 = i9++ + ++i9;
}

第一类问题:两种没什么区别,都是实现变量的加1操作

6、栈顶缓存技术

前面提过,基于栈式架构的虛拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派( instruction dispatch) 次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(ToS,Top-of-stack Cashing) 技术将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

7、动态链接(Dynamic Linking)(指向运行时常量池的方法引用)(帧数据区之一)

  • 每一个栈帧内部都包含一个指向**运行时常量池该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)** 。比如: invokedynamic指令

  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference) 保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。(多态也是通过动态链接实现的)

  • 类被加载之后,Class文件中的常量池会被复制一份到方法区,成为“运行时常量池”

    image-20210422213416813

  • 为什么需要常量池呢? 常量池的作用,就是为了提供一些符号和常量,便于指令的识别。

8、方法的调用:解析与分派

1、静态链接与动态链接

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

  • 静态链接:
    当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

  • 动态链接:

    如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

2、方法的绑定机制

对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding)绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次

  • 早期绑定:

    早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

  • 晚期绑定:

    如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

3、虚方法与非虚方法

随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式

Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虛函数的特征时,则可以使用关键字final来标记这个方法。(通过final修饰不能重写)

子类对象的多态性的使用前提(多态 <–> 虚方法)

  1. 类的继承关系
  2. 方法的重写

非虚方法:

  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法
  • 静态方法、私有方法、final方法、实例构造器、父类方法(因为java没有多继承所以调用父类的方法是非虚方法:super.xxx()可以找到调用的是哪个方法)都是非虚方法
  • 其他方法称为虚方法。

虚拟机中提供了以下几条方法调用指令

  • 普通调用指令:
    1. invokestatic:调用静态方法,解析阶段确定唯一方法版本
    2. invokespecial:调用方法、 私有及父类方法,解析阶段确定唯一方法版本
    3. invokevirtual:调用所有虛方法
    4. invokeinterface:调用接口方法
  • 动态调用指令:
    1. invokedynamic: 动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虛方法,其余的(final修饰的除外)称为虚方法

4、关于invokedynamic
  • JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进
  • 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式
  • Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器
  • 动态类型语言和静态类型语言:
    • 动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者(编译期)就是静态类型语言,反之(运行期)是动态类型语言。
    • 说的再直白一点就是,静态类型语言是判断变量自身的类型信息动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
5、方法重写的本质

Java语言中方法重写的本质

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C
  2. 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.illegalAccessError异常
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

IllegalAccessError介绍

程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

6、虚方法表
  • 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找

  • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

  • 那么虚方法表什么时候被创建?

    虚方法表会在类加载的链接阶段(解析Resolve)被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。

每个类有一个虚方法表,使用某方法时直接在这表里查找该方法在哪个类里了。

没有虚方法表的情况下,需要在当前类查找,找不到再去父类查找。

image-20210422225120851

image-20210422225328056

image-20210422225533000

image-20210422225747189

9、方法返回地址(Return Address)

  • 存放调用该方法的pc寄存器的值
  • 一个方法的结束,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令(return) ,会有返回值传递给上层的方法调用者,简称正常完成出口;

    • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
    • 在字节码指令中,返回指令包含ireturn (当返回值是boolean、 byte、char、short和int类型时使用)、lreturnfreturndreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
  2. 在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口

    方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

    image-20210423004042185

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

10、一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息

11、栈的相关面试题

  • 举例栈溢出的情况? (StackOverflowError)

    • 通过-Xss设置栈的大小OOM
  • 调整栈大小,就能保证不出现溢出吗?

    • 不能
  • 分配的栈内存越大越好吗?

    • 不是!
  • 垃圾回收是否会涉及到虚拟机栈?

    • 不会的!
  • 方法中定义的局部变量是否线程安全?

    • 具体问题具体分析
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    /**
    * 面试题:
    * 方法中定义的局部变量是否线程安全?具体情况具体分析
    *
    * 何为线程安全?
    * 如果只有一个线程才可以操作此数据,则必是线程安全的。
    * 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
    * @author shkstart
    * @create 2020 下午 7:48
    */
    public class StringBuilderTest {
    //s1的声明方式是线程安全的
    public static void method1(){
    //StringBuilder:线程不安全
    StringBuilder s1 = new StringBuilder();
    s1.append("a");
    s1.append("b");
    //...
    }
    //sBuilder的操作过程:是线程不安全的
    public static void method2(StringBuilder sBuilder){
    sBuilder.append("a");
    sBuilder.append("b");
    //...
    }
    //s1的操作:是线程不安全的
    public static StringBuilder method3(){
    StringBuilder s1 = new StringBuilder();
    s1.append("a");
    s1.append("b");
    return s1;
    }
    //s1的操作:是线程安全的
    public static String method4(){
    StringBuilder s1 = new StringBuilder();
    s1.append("a");
    s1.append("b");
    return s1.toString();
    }
    public static void main(String[] args) {
    StringBuilder s = new StringBuilder();
    new Thread(() -> {
    s.append("a");
    s.append("b");
    }).start();

    method2(s);
    }
    }

12、关于运行时数据区的五大部分的OOM与GC问题

运行时数据区 GC OOM
程序计数器(PC寄存器) × ×
虚拟机栈 ×
本地方法栈 ×
方法区

6、本地方法接口

image-20210423090739894

1、什么是本地方法?

简单地讲,一个Native Method就是一个Java调用非Java代码的接口。一个Native Method是这样一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern “C”告知C++编译器去调用一个C的函数。

“A native method is a Java method whose implementation is provided by non-java code.”

在定义一个native method时, 并不提供实现体(有些像定义一个Java interface),因为其实现体是由非java语言在外面实现的。

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序

标识符native可以与所以其它的java标识符连用,但是abstract除外

1
2
3
4
5
6
7
8
9
10
public class IHaveNatives {
// native 与 public、void
public native void Native1(int x);
// native 与 static
public native static long Native2();
// native 与 private、synchronized
private native synchronized float Native3(Object o);
// native 与 默认
native void Native4(int[] ary) throws Exception;
}

2、为什么要使用Native Method?

Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,问题就来了。

  • 与Java环境外交互:
    有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。你可以想想Java需要与一些底层系统,如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解Java应用之外的繁琐的细节。
  • 与操作系统交互:
    JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎样,它毕竟不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法我们得以用Java实现了jre的与底层系统的交互甚至JVM的一些部分就是用c写的。还有,如果我们要使用一些Java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
  • Sun’s Java
    Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分是用Java实现的,它也通过一些本地方法与外界交互。例如:类java.lang.Thread的setPriority()方法是用Java实现的,但是它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在windows 95的平台上,这个本地方法最终将调用win32 SetPriority() API。 这是一个本地方法的具体实现由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

3、现状

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service等等,不多做介绍。


7、本地方法栈(Native Method Stack)

  • Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用

  • 本地方法栈,也是线程私有的。

  • 允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackoverflowError异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虛拟机将会抛出一个OutOfMemoryError异常。
  • 本地方法是使用C语言实现的。

  • 它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。

    image-20210423094103986

  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限

    • 本地方法可以通过本地方法接口访问虛拟机内部的运行时数据区
    • 它甚至可以直接使用本地处理器中的寄存器
    • 直接从本地内存的堆中分配任意数量的内存
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。(这里存在本地方法栈只是对于HotSpot JVM而言)

  • 在HotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一


8、堆(Heap)

1、堆的核心概述

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域

  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间

    • 堆内存的大小是可以调节的。
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

    这里涉及到对象实例在堆内存中的存储方式,物理内存连续的采用指针碰撞不连续的采用动态链表

  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Al location Buffer, TLAB) 。

  • 《Java虛拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。

    • The heap is the run-time data area from which memory for all class instances and arrays is allocated.
    • 我要说的是:“几乎”所有的对象实例都在这里分配内存。——从实际使用角度看的。
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用这个引用指向对象或者数组在堆中的位置

  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 堆,是GC ( Garbage Collection, 垃圾收集器)执行垃圾回收的重点区域

    image-20210423155400525

  • 内存细分:

    现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

    • Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区
      • Young Generation Space 新生区 Young/New
        • 又被划分 为Eden区和Survivor区
      • Tenure generation space 养老区 Old/Tenure
      • Permanent Space 永久区 Perm
    • Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间
      • Young Generation Space 新生区 Young/New
        • 又被划分为Eden区和Survivor区
      • Tenure generation space 养老区 Old/ Tenure
      • Meta Space 元空间 Meta

    约定:

    • 新生区 <=> 新生代 <=> 年轻代
    • 养老区 <=> 老年区 <=> 老年代
    • 永久区 <=> 永久代

    第08章_堆和方法区图

    堆空间的内部结构:

    image-20210423160441505

2、设置堆内存大小与OOM

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项”-Xmx“和”``-Xms`”来进行设置。
    • -Xms“用于表示堆区(年轻代+老年代)的起始内存,等价于-XX: InitialHeapSize
      • -X是JVM的运行参数
      • ms是memory start
    • -Xmx”则用于表示堆区(年轻代+老年代)的最大内存,等价于-XX :MaxHeapSize
  • 查看设置的参数:
    1. jps / jstat -gc 进程id
    2. -XX:+PrintGCDetails
  • 一旦堆区中的内存大小超过“-Xmx”所指定的最大内存时,将会抛出OutOfMemoryError异常。
  • 开发中建议将初始堆内存和最大的堆内存设置成相同的值。即将-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能,避免系统压力
  • 默认情况下,初始内存大小:物理电脑内存大小 / 64;最大内存大小:物理电脑内存大小 / 4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 1. 设置堆空间大小的参数
* -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
* -X 是jvm的运行参数
* ms 是memory start
* -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
*
* 2. 默认堆空间的大小
* 初始内存大小:物理电脑内存大小 / 64
* 最大内存大小:物理电脑内存大小 / 4
* 3. 手动设置:-Xms600m -Xmx600m
* 开发中建议将初始堆内存和最大的堆内存设置成相同的值。
*
* 4. 查看设置的参数:方式一: jps / jstat -gc 进程id
* 方式二:-XX:+PrintGCDetails
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返回Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
// 手动设置600M之后:575M
System.out.println("-Xms : " + initialMemory + "M");
System.out.println("-Xmx : " + maxMemory + "M");

// System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
// System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

image-20210423165450302

结论:新生代的存储总量为一个伊甸园区加一个幸存者区(1或2,不能并存),所以虽然设置了600M,但是实际上为575M。

关于异常(Exception)与错误(Error)

Java 语言规范将派生于 Error 类或 RuntimeException 类的所有异常称为非检查型(unchecked)异常,所有其他的异常称为检查型(checked)异常

3、年轻代与老年代

  • 存储在JVM中的Java对象可以被划分为两类:

    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。
  • Java堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)

  • 其中年轻代又可以划分为Eden空间Survivor0空间Survivor1空间(有时也叫做from区to区)

    第08章_堆空间细节

  • 相关的参数设置与默认值(在开发中一般不会改变)

    • -XX:NewRatio:配置新生代与老年代在堆结构的占比。
      • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
      • 可以修改- XX: NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
    • -XX:SurvivorRatio:设置新生代中Eden区与Survivor区的比例。默认值是8
      • 在HotSpot中, Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1。
      • 当然开发人员可以通过选项“-XX:SurvivorRatio””调整这个空间比例。比如-XX: SurvivorRatio=8
      • 但在实际测试当中发现其实JVM分配Eden空间和两个Survivor空间的时候比例为6:1:1,即默认值为6而不是8,但是java官方表示的默认值就是8。如果要将比例修改为8:1:1,需要设置:
        • -XX:-UseAdaptiveSizePolicy :关闭自适应的内存分配策略 (暂时用不到)(但发现没用,还是6:1:1)
        • -XX: SurvivorRatio=8,手动设置SurvivorRatio为8。(这还算什么默认值。。。)
    • -Xmn:设置新生代的空间的大小。(一般不设置)
      • 但一般新生代与老年代的空间分配是-XX:NewRatio设置的,默认为2。
      • 当设置了-Xmn的时候,就与-XX:NewRatio冲突了
      • 这时候JVM使用的是-Xmn设置的值(JDK8下测试)
  • 几乎所有的Java对象都是在Eden区被new出来的

  • 绝大部分的Java对象的销毁都在新生代进行了。

    • IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。
  • 可以使用选项”-Xmn”设置新生代最大内存大小。

    • 这个参数一般使用默认值就可以了。

    image-20210423173418189

4、图解大小分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中停生内存碎片。

  1. new的对象先放伊甸园区。此区有大小限制。

  2. 伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器**(Minor GC)将对伊甸园区进行垃圾回收**,将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。

  3. 然后将伊甸园中的剩余对象移动到幸存者0区(to区)。

  4. 如果再次触发垃圾回收,此时JVM的垃圾回收器(Minor GC)对伊甸园区进行垃圾回收(主动:伊甸园区满即触发),会放到幸存者1区(to区)。以及同时上次幸存下来的放到幸存者0区(from区)的,如果没有回收,也会放到幸存者1区(to区)。(被动:就算幸存者1区(to区)满了也不触发Minor GC垃圾回收器)

  5. 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

  6. 啥时候能去养老区呢?可以设置次数。默认是15

    可以设置参数: -XX:MaxTenuringThreshold=进行设置。

  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发GC: Major GC, 进行养老区的内存清理。

  8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生OOM异常:
    java. lang.OutOfMemoryError:Java heap space

    image-20210423181733858

总结:

  • 针对幸存者s0,s1区的总结:复制之后有交换,谁空谁是to。
  • 关于垃圾回收:频繁在新生区收集,很少在养老区收集几乎不在永久区/元空间收集

对象分配流程图(含特殊情况):

image-20210423191247043

VisualVM状态图分析:

image-20210423192127797

常用的调优工具:

  • JDK命令行
  • Eclipse : Memory Analyzer Tool
  • Jconsole
  • VisualVM
  • Jprofiler
  • Java Flight Recorder
  • GCViewer
  • GC Easy

5、minor GC、Major GC、Full GC

1、GC的分类
  • JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。
  • 针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)
    • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
      • 新生代收集(Minor GC / Young GC):只是新生代(Eden/S0、S1)的垃圾收集
      • 老年代收集(MajorGC/Old GC):只是老年代的垃圾收集。
        • 目前,只有CMS GC会有单独收集老年代的行为。
        • 注意,很多时候Major GC会和Fu1l GC混淆使用,需要具体分辨是老年代回收还是整堆回收
      • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
        • 目前,只有G1 GC会有这种行为
    • 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
2、最简单的分代式GC策略的触发条件
  • 年轻代GC(Minor GC)触发机制:

    • 当年轻代空间不足时, 就会触发Minor GC, 这里的年轻代满指的是Eden代满,Survivor满不会引发GC。( 每次Minor GC会清理年轻代的内存。)
    • 因为Java对象大多都具备朝生夕灭的特性,所以MinorGC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
    • Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

    image-20210423195836487

  • 老年代GC (Major GC/Full GC)触发机制:

    • 指发生在老年代的GC,对象从老年代消失时,我们说“Major GC”或“Full GC”发生了。
    • 出现了Major GC,经常会伴随至少一次的Minor GC (但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。
      • 也就是在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC
    • MajorGC的速度一般会比MinorGC慢10倍以上,STW的时间更长。
    • 如果Major GC后,内存还不足,就报OOM了。
    • Major GC的速度一般会比Minor GC慢10倍以上。
  • Full GC触发机制:(后面细讲)

    • 触发Full GC执行的情况有如下五种:
      1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行
      2. 老年代空间不足
      3. 方法区空间不足
      4. 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
      5. 由Eden区、survivor space0 (From Space)区向survivor space1 (To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
    • 说明: full gc是开发或调优中尽量要避免的。这样暂时时间会短一些

6、堆空间分代思想

为什么需要把java堆分代?不分代就不能正常工作了吗?

经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有Eden、两块大小相同的Survivor (又称为from/to, s0/s1)构成,to总为空。
  • 老年代:存放新生代中经历多次GC仍然存活的对象。

其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。

而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方, 当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

7、内存分配策略(或对象提升(Promotion)规则)

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对 象年龄设为1。对象在Survivor区中每熬过一次MinorGC ,年龄就增加1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、 每个GC都有所不同)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过选项-XX: MaxTenuringThreshold来设置。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象(特别是朝生夕死的大对象),防止过多的STW
  • 长期存活(15岁)的对象分配到老年代
  • 动态对象年龄判断
    • 如果Survivor 区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • 空间分配担保
    • -XX: HandlePromotionFailure

8、为对象分配内存:TLAB

1、为什么有TLAB ( Thread Local Allocation Buffer ) ?
  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
2、什么是TLAB?
  • 从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

第08章_TLAB

3、TLAB相关说明
  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。默认为开启
  • 默认情况下,TLAB空间的内存非常小,**仅占有整个Eden空间的1%**,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

第08章_对象分配过程

9、小结堆空间的常用的JVM参数设置

官网说明

  • -XX: +PrintFlagsInitial:查看所有的参数的默认初始值
  • -XX: +PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -Xms:初始堆空间内存 (默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio: 配置新生代与老年代在堆结构的占比
  • -XX: SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX: MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX: +PrintGCDetails:输出详细的GC处理日志
    • 打印gc简要信息:
    • -XX: +PrintGC
    • -verbose: gc
  • XX: HandlePromotionFailure:是否设置空间分配担保

在发生MinorGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看-XX: HandlePromotionFailure设置值是否允许担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
      • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
      • 如果小于,则改为进行一次Full GC。
    • 如果HandlePromotionFailure=false,则改为进行一次Full GC。

在JDK6 Update24(JDK7)之后,HandlePromotionFailure参数不会再影响到虛拟机的空间分配担保策略,观察0penJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC, 否则将进行Full GC。

10、堆是分配对象的唯一选择吗?

1、堆是分配对象的唯一选择吗?

在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

随着JIT编译期的发展逃逸分析技术逐渐成熟,栈上分配标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术

此外,前面提到的基于openJDK深度定制的TaoBaoVM,其中创新的GCIH (GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。

2、逃逸分析概述
1、什么是逃逸分析?
  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段

  • 这是一种可以有效减少Java程序中同步负载内存堆分配压力跨函数全局数据流分析算法

  • 通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

  • 逃逸分析的基本行为就是分析对象动态作用域:

    • 一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸

      1
      2
      3
      4
      5
      public void my_method() {
      V v = new V();
      //use v
      v = null;
      }

      没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。

    • 一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

      1
      2
      3
      4
      5
      6
      public static StringBuffer createStringBuffer(String s1, String s2) {
      StringBuffer sb = new StringBuffer() ;
      sb.append(s1);
      sb.append (s2);
      return sb;
      }

      发生了逃逸的对象StringBuffer,作为返回值被返回回去了,在方法外可以被调用

      改进代码,让StringBuffer sb逃不出去:(转换为String类型)

      1
      2
      3
      4
      5
      6
      public static String createStringBuffer(String s1, String s2) {
      StringBuffer sb = new StringBuffer();
      sb.append(s1) ;
      sb.append(s2) ;
      return sb.toString() ;
      }
2、逃逸分析的几种情况:

如何快速的判断是否发生了逃逸分析,大家就看**new的对象实体是否有可能在方法外被调用**。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 逃逸分析
* 如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否有可能在方法外被调用。
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
//思考:如果当前的obj引用声明为static的?
仍然会发生逃逸。
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
//getInstance().xxx()同样会发生逃逸
EscapeAnalysis e = getInstance();
}
}
3、逃逸分析相关的参数设置:
  • 在JDK 6u23(JDK7)版本之后,HotSpot中默认就已经开启了逃逸分析
  • 如果使用的是较早的版本,开发人员则可以通过:
    • 选项“-XX: +DoEscapeAnalysis“显式开启逃逸分析
    • 通过选项“-XX: +PrintEscapeAnalysis“查看逃逸分析的筛选结果。
4、结论:

开发中能使用局部变量的,就不要使用在方法外定义。

5、逃逸分析的代码优化

使用逃逸分析,编译器可以对代码做如下优化:

  1. 栈上分配将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  2. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
6、代码优化之栈上分配
  • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  • 常见的栈上分配的场景
    • 在逃逸分析中,已经说明了。分别是给成员变量赋值方法返回值实例引用传递

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 栈上分配测试
* -Xmx256 -Xms256 -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 先关闭逃逸分析:-XX:-DoEscapeAnalysis
* 在打开逃逸分析:-XX:+DoEscapeAnalysis
* 观察对比
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
// 查看执行时间
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未发生逃逸
}
static class User {}
}

结果分析:

在关闭逃逸分析的时候:代码执行时间:55ms,发生了GC

在开启逃逸分析的时候:代码执行时间:4ms,并没有发生GC

结论:

逃逸分析优化了对非逃逸对象的内存分配,实现了栈上分配。加快了程序的执行效率,并消除了GC,没有了STW,用户线程不会被阻碍。

7、代码优化之同步省略(锁消除)
  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能。
  • 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的
    同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

代码:

1
2
3
4
5
6
7
8
public class SynchronizedTest {
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
}

代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。(在字节码文件下依旧存在synchronized的身影,即:字节码当中的monitorenter与monitorexit中间包裹的部分,只是在运行阶段进行了代码优化)

优化成:

1
2
3
4
public void f() {
Object hollis = new Object() ;
System.out.println(hollis) ;
}
8、代码优化之标量替换

**标量(Scalar)**是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做**聚合量(Aggregate)**,Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
* 先关闭标量替换:-XX:-EliminateAllocations
* 在打开标量替换:-XX:+EliminateAllocations
* 观察对比
*/
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
}

结果分析:

在关闭标量替换的时候:代码执行时间:57ms,发生了GC

在开启标量替换的时候:代码执行时间:4ms,并没有发生GC

结论:

  • 代码优化:(在alloc()方法中)

    1
    2
    3
    4
    public static void alloc() {
    public int id = 5;
    public String name = "www.atguigu.com";
    }

    可以看到,Uesr这个聚合量经过逃逸分析后,发现它并没有逃逸,就被替换成两个标量了。

    那么标量替换有什么好处呢?

    就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

    标量替换为栈上分配提供了很好的基础。

  • 另外,在开启-XX:+DoEscapeAnalysis即逃逸分析,同时关闭标量替换的时候为什么还会存在GC?

    可以理解为(弹幕,不一定对):

    • 栈上分配是要基于标量替换,即使开启了逃逸分析但是没有开启标量替换,他还是会在堆上分配。
    • 对象未发生逃逸 + 开启标量替换 = 栈上分配
    • 可以将“栈上分配”理解为1个概念,具体要通过逃逸分析和标量替换两个参数决定
  • 标量替换参数设置:

    参数-XX: +EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。

  • 上述代码在主函数中进行了1亿次alloc。调用进行对象创建,由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。如果堆空间小于这个值,就必然会发生GC。 使用如下参数运行上述代码:(逃逸分析在JDK7之后已经自动添加,这里针对的是服务器端)

    • -server
    • -Xmx100m
    • -Xms100m
    • -XX: +DoEscapeAnalysis
    • -XX: +PrintGC
    • -XX: +EliminateAllGcations

    这里使用参数如下:

    • 参数-server:启动Server模式,因为在Server模式下,才可以启用逃逸分析。(java的JVM默认就是一个Server模式,不用我们手动开启)
    • 参数-XX:+DoEscapeAnalysis:启用逃逸分析
    • 参数-Xmx10m:指定了堆空间最大为10MB
    • 参数-xx: +PrintGC:将打印GC日志。
    • 参数-XX:+E1 iminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配。
9、逃逸分析总结
  • 关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
  • 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程
  • 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段
  • 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
  • 目前很多书籍还是基于JDK 7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

11、堆总结

  • 年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。
  • 老年代放置长生命周期的对象,通常都是从Survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。
  • 当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGC。当GC发生在老年代时则被称为MajorGC或者Ful1GC。一般的,MinorGC 的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

9、方法区(Method Area)

1、栈、堆、方法区的交互关系

从线程是否共享的角度来看

image-20210424010349347

从代码看出栈、堆、方法区的交互关系:

1
Person person = new Person();

image-20210424010520101

2、方法区的理解

1、官方文档

image-20210424010921229

2、方法区的位置

《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpot JVM而言,方法区还有一个别名叫做**Non-Heap (非堆)**,目的就是要和堆分开。

所以,方法区看作是一块独立于Java堆的内存空间

3、方法区和基本理解
  • 方法区(Method Area) 与Java堆一样,是各个线程共享的内存区域
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虛拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space (JDK7)或者 java.lang.OutOfMemoryError: Metaspace(JDK8)
    • 加载大量的第三方的jar包;Tomcat 部署的工程过多(30-50个),大量动态的生成反射类
  • 关闭JVM就会释放这个区域的内存。
4、Hotspot中方法区的演进
  • JDK7及以前,习惯上把方法区,称为永久代JDK8开始,使用元空间取代了永久代。

    In JDK8,classes metadata is now stored in the native heap and this space is called Metaspace.

  • 本质上,方法区和永久代并不等价。仅是对hotspot而言的。《Java虚拟机规范》对如何实现方法区,不做统一要求。 例如: BEA JRockit/ IBM J9中不存在永久代的概念。

    • 现在来看,当年使用永久代,不是好的idea。导致Java程序更容易OOM (超过-XX : MaxPermSize上限)

    第08章_堆和方法区图

  • 而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间( Metaspace)来代替。

    image-20210424014427319

  • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虛拟机设置的内存中,而是使用本地内存

  • 永久代、元空间二者并不只是名字变了,内部结构也调整了。

    • 根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM异常

3、设置方法区大小与OOM

  • 方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。

  • JDK7及以前:

    • 通过-XX:PermSize来设置永久代初始分配空间。默认值是20.75M

    • -XX:MaxPermSize来设定永久代最大可分配空间。32位机器默认是64M64位机器模式是82M

    • 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError : PermGen space

      image-20210424014940690

  • jdk8及以后:

    • 元数据区大小可以使用参数-XX :MetaspaceSize-XX :MaxMetaspaceSize指定,替代上述原有的两个参数。

    • 默认值依赖于平台windows下,-XX:MetaspaceSize是21M, -XX: MaxMetaspaceSize的值是-1, 即没有限制

    • 参数设置方法:

      • jdk7及以前:-XX:PermSize=100m -XX:MaxPermSize=100m
      • jdk8及以后:-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m(这个一般不会改)
    • 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError: Metaspace

    • -XX : MetaspaceSize
      设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX :MetaspaceSize值为21MB。这就是初始的高水位线,一 旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值。

      image-20210424015216603

    • 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值

4、如何解决OOM

  1. 要解决OOM异常或heap space的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer) 对dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(MemoryLeak)**还是内存溢出(Memory Overflow)内存泄漏堆积会导致内存溢出,所以判断内存溢出第一步是查看内存是否泄漏**。
  2. 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
  3. 如果不存在内存泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx与-Xms) ,与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

5、方法区的内部结构

image-20210424020826940

1、方法区存储的内容

《深入理解Java虚拟机》书中对方法区(Method Area)存储内容描述如下:

它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

image-20210424020942369

2、方法区和内部结构
1、类型信息

对每个加载的类型( 类calss、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)
  2. 这个类型直接父类的完整有效名(对于interface或是java. lang.object, 都没有父类)
  3. 这个类型的修饰符(public, abstract, final的某个子集)
  4. 这个类型直接接口的一个有序列表
2、域(Field)信息

JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。

域的相关信息包括:

  • 域名称
  • 域类型
  • 域修饰符:public,private,protected,static,final,volatile,transient的某个子集
3、方法(Method)信息

JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序:

  • 方法名称
  • 方法的返回类型(或void)
  • 方法参数的数量和类型(按顺序)
  • 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)
  • 方法的字节码 (bytecodes)、操作数栈、局部变量表及大小(abstract和native方法除外)
  • 异常表(abstract和native方法除外)
    • 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
4、non-final的类变量
  • 静态变量和类关联在一起,随着类的加载而加载,它们成为类数据在逻辑上的一部分。
  • 类变量被类的所有实例共享,即使没有类实例时你也可以访问它
  • javac编译器自动搜集字节码中的类变量的赋值动作和静态代码块组成的语句
5、全局常量:static final

被声明为final的类变量的处理方法则不同,每个全局常量在编译的时候就会被分配

image-20210424024056293

3、运行时常量池 VS 常量池

image-20210424024352733

  • 方法区,内部包含了运行时常量池

  • 字节码文件,内部包含了常量池

  • 方法区的运行时常量池就是class字节码文件中的常量池经过类加载器进行加载之后存放进内存之后得到。

    但由于方法区的运行时常量池是具备动态性,所以可能比字节码文件里的常量池要大。

  • 要弄清楚方法区,需要理解清楚ClassFile,因为加载类的信息都在方法区。

  • 要弄清楚方法区的运行时常量池,需要理解清楚ClassFile中的常量池。

  • 官网描述如下

    image-20210424030341079

  • 一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table) ,**包括各种字面量和对类型、域和方法的符号引用**。

    image-20210424030544494

4、为什么需要常量池?

一个java源文件中的类、接口,编译后产生一个字节码文件。而Java中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用**。在动态链接的时候会用到运行时常量池**。

比如如下的代码:

1
2
3
4
5
6
public class SimpleClass {
public void sayHel1o() {
System.out.println("hello");
Object obj = new Object();
}
}

Object obj = new Object();经过编译之后会生成如下字节码文件:

1
2
3
0:	new #2				// Class java/ lang/ object
1: dup
2: invokespecial #3 // Method java/ lang/object "<init>"( ) V

虽然编译过后的class文件只有194字节,但是里面却使用了String、System、 PrintStream及object等结构。这里代码量其实已经很小了。如果代码多,引用到的结构会更多!这里就需要常量池了!

5、常量池中的内容

几种在常量池内存储的数据类型包括:

  • 数量值
  • 字符串值
  • 类引用
  • 字段引用
  • 方法引用
6、常量池小结

常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型

7、运行时常量池
  • 运行时常量池( Runtime Constant Pool) 是方法区的一部分
  • 常量池表( Constant Pool Table) 是Class文件的一部分用于存放编译期生成的各种字面量与符号引用**,这部分内容将在类加载后存放到方法区的运行时常量池中**。
  • 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  • JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过**索引访问**的。
  • 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址
    • 运行时常量池,相对于Class文件常量池的另一重要特征是:**具备动态性**。
      • String. intern( )
  • 运行时常量池类似于传统编程语言中的符号表(symboltable),但是它所包含的数据却比符号表要更加丰富一些。
  • 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会抛OutOfMemoryError异常。

6、方法区使用举例

image-20210424165338137

image-20210424165459908

7、方法区的演进细节

1、方法区的演进

首先明确:只有HotSpot才有永久代

BEA JRockit、 IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。

Hotspot中方法区的变化:

版本 描述
jdk1.6及之前 有永久代(permanent generation),静态变量存放在永久代上
jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
jdk1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆

第08章_方法区的演进细节-hotspot

2、元空间 VS 永久代

永久代为什么要被元空间替换

  • 随着Java8 的到来,HotSpot VM中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。

  • 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间

  • 这项改动是很有必要的,原因有:

    1. 为永久代设置空间大小是很难确定的

      在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。

      “Exception in thread ‘dubbo client x.x connector’ java.lang OutOfMemoryError: PermGen space

      而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。

    2. 永久代进行调优是很困难的。

3、StringTable的调整

StringTable为什么要调整?

jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才会触发。而full gc是老年代的空间不足、永久代不足时才会触发。这就导致StringTable回收效率不高。而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

4、静态变量的位置

结论:静态引用对应的对象实体始终都存在堆空间

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 《深入理解Java虚拟机》中的案例:
* staticObj、instanceObj、localObj存放在哪里?
*/
public class StaticObjTest {
static class Test {
static ObjectHolder staticObj = new ObjectHolder();
ObjectHolder instanceObj = new ObjectHolder();

void foo() {
ObjectHolder localObj = new ObjectHolder();
System.out.println("done");
}
}

private static class ObjectHolder {
}

public static void main(String[] args) {
Test test = new StaticObjTest.Test();
test.foo();
}
}

staticObj(静态变量)随着Test的类型信息存放在方法区instanceObj(实例变量)随着Test的对象实例存放在Java堆localObject(局部变量)则是存放在foo( )方法栈帧的局部变量表中。

三个对象的数据在内存中的地址都落在Eden区范围内

所以结论:只要是对象实例必然会在Java堆中分配

接着,找到了一个引用该staticObj对象的地方,是在一个java.lang.Class的实例里,并且给出了这个实例的地址,通过Inspector查看该对象实例,可以清楚看到这确实是一个java.lang.Class类型的对象实例,里面有一个名为staticObj的实例字段:

image-20210424172219770

从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中,但方法区该如何实现,《Java虚拟机规范》 并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。

JDK 7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点。

8、方法区的垃圾回收

有些人认为方法区如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》 对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载)。

一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虛拟机对此区域未完全回收而导致内存泄漏。

**方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量不再使用的类型**。

  • 先来说说方法区内**常量池之中主要存放的两大类常量:字面量符号引用**。
    字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。
  • 而符号引用则属于编译原理方面的概念,包括下面三类常量:
    1. 类和接口的全限定名
    2. 字段的名称和描述符
    3. 方法的名称和描述符
  • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
  • 回收废弃常量与回收Java堆中的对象非常类似。
  • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
    1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
    2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
    3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
  • Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX: +TraceClass-Loading-XX: +TraceClassUnLoading查看类加载和卸载信息
  • 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

9、总结

第09章_小结

10、常见面试题

  • 百度:
    • 三面:说一下JVM内存模型吧,有哪些区?分别干什么的?
  • 蚂蚁金服:
    • Java8的内存分代改进
    • JVM内存分哪几个区,每个区的作用是什么?
    • 一面: JVM内存分布/内存结构?栈和堆的区别?堆的结构?为什么两个survivor区?
    • 二面: Eden和Survior的比例分配
  • 小米:
    • jvm内存分区,为什么要有新生代和老年代
  • 字节跳动:
    • 二面: Java的内存分区
    • 二面:讲讲jvm运行时数据库区
    • 什么时候对象会进入老年代?
  • 京东:
    • JVM的内存结构,Eden和Survivor比例。
    • JVM内存为什么要分成新生代,老年代,持久代。新生代中为什么要分为Eden和Survivor。
  • 天猫:
    • 一面: Jvm内存模型以及分区,需要详细到每个区放什么。
    • 一面: JVM的内存模型,Java8做了什么修改
  • 拼多多:
    • JVM 内存分哪几个区,每个区的作用是什么?
  • 美团:
    • java内存分配
    • jvm的永久代中会发生垃圾回收吗?
    • 一面: jvm内存分区,为什么要有新生代和老年代?

10、对象的实例化、内存布局与访问定位

1、对象的实例化

1、大厂面试题
  • 美团:
    • 对象在JVM中是怎么存储的?
    • 对象头信息里面有哪些东西?
  • 蚂蚁金服:
    • 二面: java对象头里有什么
2、对象实例化

第10章_对象的实例化

创建对象的字节码解析:

image-20210424193206898

创建步骤:

  1. 判断对象对应的类是否加载、链接、初始化

    虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。( 即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。

  2. 为对象分配内存

    首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。

    如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小

    • 如果内存规整,使用指针碰撞

      如果内存是规整的,那么虚拟机将采用的是**指针碰撞法(Bump The Pointer)**来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带有compact(整理)过程的收集器时,使用指针碰撞。

    • 如果内存不规整,虚拟机需要维护-一个列表,使用空闲列表分配

      如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为“空闲列表(Free List)“。

    说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  3. 处理并发安全问题

    在分配内存空间时,另外一个问题是及时保证new对象时候的线程安全性:创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用了两种方式解决并发问题:

    1. CAS ( Compare And Swap )失败重试、区域加锁:保证指针更新操作的原子性;
    2. TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB ,Thread Local Allocation Buffer) 虚拟机是否使用TLAB,可以通过-XX: +/-UseTLAB参数来设定(’+’:打开 ‘-‘:关闭)。
  4. 初始化分配到的空间

    内存分配结束,虚拟机将分配到的内存空间都**初始化为零值(不包括对象头)**。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

  5. 设置对象的对象头

    将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

  6. 执行init方法进行初始化

    在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

    因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

2、对象的内存布局

第10章_内存布局

小结:图示

第10章_图示对象的内存布局

3、对象的访存定位

第10章_对象访问定位

图示:JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?

image-20210424190106896

1、句柄访问

图示:

第10章_方式1:句柄访问

好处:reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。

2、直接指针(HotSpot采用)

图示:

第10章_方式2:使用直接指针访问


11、直接内存(Direct Memory)

1、直接内存概述

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
  • 直接内存是在Java堆外的、直接向系统申请的内存区间
  • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
  • 通常,访问直接内存的速度会优于Java堆。即读写性能高。
    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存
    • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区

2、IO VS NIO

IO NIO (New IO / Non-Blocking IO:非阻塞IO)
实现 byte[] / char[] Buffer(缓存区)
形式 Stream(流) Channel(管道)

3、直接缓存区(IO) VS 非直接缓存区(NIO)

读写文件,需要与磁盘交互,需要由用户态切换到内核态。在内核态时,需要内存如下图的操作。

使用IO,见下图。这里需要两份内存存储重复数据,效率低

image-20210424225618054

使用NIO时,如下图。

操作系统划出的直接缓存区可以被java代码直接访问,只有一份。 NIO适合对大文件的读写操作

image-20210424225837934

4、直接内存的OOM异常

  • 直接内存也可能导致OutOfMemoryError异常

    image-20210424231245461

  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • 缺点

    • 分配回收成本较高
    • 不受JVM内存回收管理
  • 直接内存大小可以通过MaxDirectMemorySize设置

  • 如果不指定,默认与堆的最大值-Xmx参数值一致

简单理解:

java process memory = java heap + native memory

image-20210424231440926


12、执行引擎

1、执行引擎概述

1、执行引擎的作用
  • 执行引擎是Java虛拟机核心的组成部分之一。
  • “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式
  • JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。
  • 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
2、执行引擎的工作过程
  • 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
  • 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址
  • 当前方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息

从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程输出的是执行结果

2、Java代码编译和执行过程

程序执行过程

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。

Java代码编译是由Java源码编译器来完成,流程图如下所示:

image-20210425005150269

Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:

image-20210425005213179

问题1:什么是解释器( Interpreter),什么是JIT编译器?

  • 解释器:当Java虛拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • JIT (Just In Time Compiler) 编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言
  • 解释器:边逐行翻译边运行
  • 编译器:一起编译好再执行

问题2:为什么说Java是半编译半解释型语言?

  • JDK1.0时代,将Java语言定位为“解释执行”还是比较准确的。再后来,Java也发展出可以直接生成本地代码的编译器。

  • 现在JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。

  • 经过编译器编译之后可以在方法区中进行缓存(热点代码)

    image-20210425005819291

3、机器码、指令、汇编语言

1、理解执行引擎

第12章_理解执行引擎

2、机器码
  • 各种用二进制编码方式表示的指令,叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。
  • 机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。
  • 用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快
  • 机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同
3、指令与指令集
  • 指令
    • 由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好
    • 由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
  • 指令集
    • 不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。
    • 如常见的
      • x86指令集,对应的是x86架构的平台
      • ARM指令集,对应的是ARM架构的平台
4、汇编语言
  • 由于指令的可读性还是太差,于是人们又发明了汇编语言。
  • 在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol) 或标号(Label)代替指令或操作数的地址
  • 在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
    • 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
5、高级语言
  • 为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言
  • 当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序编译程序

第12章_机器语言、汇编、高级语言

6、字节码
  • 字节码是一种**中间状态(中间码)的二进制代码(文件)**,它比机器码更抽象,需要直译器转译后才能成为机器码
  • 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。(实现跨平台
  • 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
    • 字节码的典型应用为Java bytecode。

第01章_Java语言的跨平台性

7、C/C++源程序执行过程

编译过程又可以分成两个阶段:编译和汇编。

  • 编译过程:是读取源程序(字符流),对之进行词法和语法的分析,将高级语言指令转换为功能等效的汇编代码
  • 汇编过程:实际上指把汇编语言代码翻译成目标机器指令的过程。

image-20210425020914156

4、解释器

1、解释器概述

JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。

为什么需要字节码文件作为中间过渡,而不是采用将java源文件直接编译成对应的不同操作系统的机器指令的方式(此方式也可以实现跨平台)?

字节码文件是为了提高编译器的效率,同时也是Java虚拟机被称为跨语言的平台的基础。

image-20210425021622259

2、解释器的工作机制(或工作任务)
  • 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
  • 当一条字节码指令被解释执行完成后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
3、解释器分类

在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器

  • 字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
  • 模板解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
    • 在HotSpot VM中,解释器主要由Interpreter模块Code模块构成。
      • Interpreter模块:实现了解释器的核心功能
      • Code模块:用于管理HotSpot VM在运行时生成的本地机器指令
4、解释器现状
  • 由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些
    C/C++程序员所调侃。
  • 为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
  • 不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

5、JIT编译器

1、Java代码的执行分类
  1. 第一种是将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行

  2. 第二种是编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT, Just In Time)将方法编译成机器码后再执行

    程序执行过程

  • HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代
    码的时间和直接解释执行代码的时间。
  • 在今天,Java程序的运行性能早已脱胎换骨,已经达到了可以和C/C++程序一 较高下的地步。
2、为什么需要解释器?

有些开发人员会感觉到诧异,既然HotSpot VM中 已经内置JIT编译器了,那么为什么还需要再使用解释器来“拖累”程序的执行性能呢?比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

首先明确:

当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。

总结成一句话:解释器的响应速度快,执行速度慢;而编译器的响应速度慢,执行速度快。

所以:

尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java 虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率

同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的“逃生门”

3、HotSpot JVM 的执行方式

当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率

相关案例:

注意解释执行与编译执行在线上环境微妙的辩证关系。机器在热机状态可以承受的负载要大于冷机状态。如果以热机状态时的流量进行切流,可能使处于冷机状态的服务器因无法承载流量而假死。

热机状态:已经启动很长时间;冷机状态:刚刚启动

在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。——阿里团队

image-20210425024022961

4、JIT即时编译器
1、概念解释
  • Java语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“ 编译器的前端”更准确一些)把. java文件转变成.class文件的过程;
  • 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。
  • 还可能是指使用静态提前编译器 (AOT编译器,Ahead Of Time Compiler) 直接把.java文件编译成本地机器代码的过程。

相关的编译器:

  • 前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器( ECJ)
  • JIT编译器:HotSpot VM的C1、C2编译器
  • AOT编译器:GNU Compiler for the Java (GCJ)、Excelsior JET。
2、是否选择使用编译器

热点代码及探测方式

当然是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

3、热点代码及探测方式
  • 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR (On Stack Replacement)编译

  • 一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?

    必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能

  • 目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测

  • 采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter) 。

    • 方法调用计数器用于统计方法的调用次数
    • 回边计数器则用于统计循环体执行的循环次数
4、方法调用计数器
  • 这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次在Server 模式下是10000 次。超过这个阈值,就会触发JIT编译。

  • 这个阈值可以通过虛拟机参数-XX:CompileThreshold来人为设定。

  • 当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

  • 方法调用计数器的工作流程图:

    image-20210425024857559

    简化版本:

    第12章_方法调用计数器

  • 热度衰减

    • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time) 。
    • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
    • 另外,可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
5、回边计数器
  • 它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge) 。显然,建立回边计数器统计的目的就是为了触发OSR编译

  • 回边计数器的执行流程图:

    第12章_回边计数器

6、HotSpot VM可以设置程序执行方式

缺省情况下HotSpot VM是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显式地为Java虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

  • -Xint:完全采用解释器模式执行程序;

  • -Xcomp: 完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。

  • -Xmixed:采用解释器+即时编译器的混合模式共同执行程序。(默认)

    image-20210425031931789

7、HotSpot VM中JIT分类

在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器,如下所示:

  • -client:指定Java虚拟机运行在Client模式下,并使用C1编译器;

    • C1编译器会对字节码进行简单和可靠的优化耗时短。以达到更快的编译速度。
  • -server:指定Java虚拟机运行在Server模式下,并使用C2编译器。(对于84位的操作系统默认就是Srver模式,不能修改)

  • 官方

    image-20210425030403484

    image-20210425030645445

    • C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控信息进行激进优化。

不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务。

C1和C2编译器不同的优化策略:

  • 在不同的编译器上有不同的优化策略,C1骗译器上主要有方法内联去虚拟化冗余消除
    • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
    • 去虚拟化:对唯一的实现类进行内联
    • 冗余消除:在运行期间把一些不会执行的代码折叠掉
  • C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:
    • 标量替换:用标量值代替聚合对象的属性值
    • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
    • 同步消除:清除同步操作,通常指synchronized

总结:

  • 一般来讲,JIT编译出来的机器码性能比解释器高
  • C2编译器启动时长比C1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器。
8、最后补充
  • 关于C1与C2:
    • 自JDK10起,HotSpot又加入一个全新的即时编译器:Graal编译器
    • 编译效果短短几年时间就追评了C2编译器。未来可期。
    • 目前,带着“实验状态”标签,需要使用开关参数去激活,才可以使用。
      • -XX: +UnlockExperimentalVMOptions
      • -XX: +UseJVMCICompiler
  • 关于AOT编译器:(AOT VS JIT)
    • jdk9引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)
    • Java 9引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。
    • 所谓AOT编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
      • .java -> .class -(jaotc)-> .so(机器指令)
    • 最大好处:Java虛拟机加载已经预编译成二进制库,可以直接执行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验
    • 缺点:
      • 破坏了java“一次编译,到处运行”,必须为每个不同硬件、os编译对应的发行包。
      • 降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。
      • 还需要继续优化中,最初只支持Linux x64 java base

13、String Table

1、String的基本特性

  • String:字符串,使用一对""引起来表示。

    • // 字面量的方式
      String str = "Hello";
      // new的方式
      String string = new String("Hi");
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44

      - String声明为final的,**不可被继承**

      - String实现了Serializable接口:表示字符串是**支持序列化**的

      - String实现了Comparable接口:表示String可以**比较大小**

      - String在JDK8及以前内部定义了final char[] value用于存储字符串数据。**JDK9时改为byte[]**

      - [官方](http://openjdk.java.net/jeps/254
      ):

      ![image-20210425194526787](JVM/image-20210425194526787.png)

      ![image-20210425201622470](JVM/image-20210425201622470.png)

      - String:代表不可变的字符序列。简称:不可变性

      - 当对字符串重新赋值时,需要重写指定内存区域赋值,不能使用原有的value进行赋值
      - 当对现有的字符串进行连接操作时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值
      - 当调用String的replace()方法修改指定字符或字符串时,也需要重新指定内存区域赋值,不能使用原有的value进行赋值。

      - 通过字面量的方式(区别与new)给一个字符串赋值,此时的字符串值声明在字符串常量池中。

      - 字符串常量池中是不会存储相同内容的字符串的

      - String的String Pool时一个固定大小的Hashtable,默认值大小长度是1009。如果放进String Pool的String非常多,就会造成Hash冲突严重,从而导致链表会很长。而链表长了后直接会造成的影响就是当调用String.intern时性能会大幅下降。
      - 使用`-XX: StringTableSize`可设置StringTable的长度
      - 在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多的就会导致效率下降很快。StringTableSize设置没有要求
      - 在JDK7中,StringTable的长度默认值是60013,StringTableSize设置没有要求
      - 在JDK8开始,设置StringTable的长度的话,1009是可设置的最小值。



      #### 2、String的内存分配

      - 在Java语言中有8种基本数据类型和一种比较特殊的类型String。这些类型为了使它们在运行过程中速度更快、更节省内存,都提供了一种常量池的概念。

      - 常量池就类似一个Java系统级别提供的缓存。8种基本数据类型的常量池都是系统协调的,**String类型的常量池比较特殊。它的主要使用方法有两种**:

      - 直接使用双引号`""`声明出来的String对象会直接存储在常量池中。

      - ```java
      String info = "Hello"
    • 如果不是用双引号""声明的String对象,可以使用String提供的intern()方法。

  • Java6及以前,字符串常量池存放在永久代

  • Java7中Oracle的工程师对字符串池的逻辑做了很大的改变,即将字符串常量池的位置调整到Java堆内

    • 所有的字符串都保存在堆(Heap)中,和其他普通对象一样,这样可以让你在进行调优应用时仅需要调整堆大小就可以了。

    • 字符串常量池概念原本使用比较多,但是这个改动使得我们有足够的理由让我们重新考虑在Java7中使用String.intern()。

    • Java8元空间,字符串常量池在堆

      image-20210425213811970

  • 为什么StringTable要调整?

    • permSize默认比较小
    • 永久代垃圾回收的频率低

    官网

    image-20210425214013347

3、String的基本操作

Java语言规范里要求完全相同的字符串字面量,应该包含同样的Unicode字符序列(包含同一份码点序列的常量),并且必须是指向同一个String类实例

代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Memory {
public static void main(String[] args) {//line 1
int i = 1;//line 2
Object obj = new Object();//line 3
Memory mem = new Memory();//line 4
mem.foo(obj);//line 5
}//line 9
private void foo(Object param) {//line 6
String str = param.toString();//line 7
System.out.println(str);
}//line 8
}

image-20210425215451317

A string is created in line 7.it goes in the String Pool in the heap space and a reference is created in the foo() stack space for it.

4、字符串拼接操作

  1. 常量与插入的拼接结果在常量池,原理是编译期优化
  2. 常量池中不会存在相同内容的常量
  3. 只要其中有一个是变量,结果就在堆(非字符串常量池)中。变量拼接原理是StringBuilder(底层新建了一个StringBuilder对象进行字符串拼接)
  4. 如果拼接的结果调用intern()方法,则主动将常量池中还没有的字符串对象放入池中,并返回此对象地址。

相关案例:

总结:

  • 如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果

  • intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。

  • 如下的s1 + s2 的执行细节:(变量s是我临时定义的)

    • StringBuilder s = new StringBuilder();

    • s.append(“a”)

    • s.append(“b”)

    • s.toString() –> 约等于 new String(“ab”)

      StringBuilder的toString()方法的new里面放的是char数组,不会在常量池创建对,而new String(“ab”)这里放的是字面量,会先在常量池创对象

    补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer

  • 字符串拼接操作不一定使用的是StringBuilder!

    • 如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
  • 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。

    final修饰的变量一旦赋值后就不能再次赋值,所以可以做编译期优化,但是如果使用final String s = new String(“a”);则不会做编译期优化,必须运行时才能确定。

  • 体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!

    • 详情:

      • StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
      • 使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
      • 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
    • 改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:

      StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
public class StringTest5 {
@Test
public void test1(){
String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
/*
* 最终.java编译成.class,再执行.class
* String s1 = "abc";
* String s2 = "abc"
*/
System.out.println(s1 == s2); //true
System.out.println(s1.equals(s2)); //true
}

@Test
public void test2(){
String s1 = "javaEE";
String s2 = "hadoop";

String s3 = "javaEEhadoop";
String s4 = "javaEE" + "hadoop";//编译期优化
//如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
String s5 = s1 + "hadoop";
String s6 = "javaEE" + s2;
String s7 = s1 + s2;

System.out.println(s3 == s4);//true
System.out.println(s3 == s5);//false
System.out.println(s3 == s6);//false
System.out.println(s3 == s7);//false
System.out.println(s5 == s6);//false
System.out.println(s5 == s7);//false
System.out.println(s6 == s7);//false
//intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
//如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
String s8 = s6.intern();
System.out.println(s3 == s8);//true
}
@Test
public void test3(){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
/*
如下的s1 + s2 的执行细节:(变量s是我临时定义的)
① StringBuilder s = new StringBuilder();
② s.append("a")
③ s.append("b")
④ s.toString() --> 约等于 new String("ab")

补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
*/
String s4 = s1 + s2;//
System.out.println(s3 == s4);//false
}
/*
1. 字符串拼接操作不一定使用的是StringBuilder!
如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
*/
@Test
public void test4(){
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);//true
}
//练习:
@Test
public void test5(){
String s1 = "javaEEhadoop";
String s2 = "javaEE";
String s3 = s2 + "hadoop";
System.out.println(s1 == s3);//false

final String s4 = "javaEE";//s4:常量
String s5 = s4 + "hadoop";
System.out.println(s1 == s5);//true

}
/*
体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
详情:① StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。

改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
*/
@Test
public void test6(){
long start = System.currentTimeMillis();
// method1(100000);//4014
method2(100000);//7

long end = System.currentTimeMillis();

System.out.println("花费的时间为:" + (end - start));
}
public void method1(int highLevel){
String src = "";
for(int i = 0;i < highLevel;i++){
src = src + "a";//每次循环都会创建一个StringBuilder、String
}
// System.out.println(src);

}
public void method2(int highLevel){
//只需要创建一个StringBuilder
StringBuilder src = new StringBuilder();
for (int i = 0; i < highLevel; i++) {
src.append("a");
}
// System.out.println(src);
}
}

image-20210425221328973

image-20210425221607026

image-20210425221622886

image-20210425221651006

StringBuilder执行拼接操作:

  • 好处:从始至终就创建了一个stringBuilder对象去执行append操作
  • 改进空间:
    • 可以使用StringBuilder的带参数的构造器,指定大小
    • 如果调用默认构造器,初始容量16,进行大量存储操作时,会导致频繁扩容(数组大小是不可变的,所以得新建数组,然后进行数组间的copy,中间也会产生垃级对象,耗时耗力。

5、intern()的使用

1、Java.lang.String.intern()的相关解释

image-20210426012219855

2、intern()的使用

如果不是用双引号声明的String对象,可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。

  • 比如:

    • String myInfo = new String("I love atguigu").intern();
      
      1
      2
      3
      4
      5

      - 也就是说,如果在任意字符串上调用String.intern方法,那么其返回结果所指向的那个类实例,必须和直接以常量形式出现的字符串实例完全相同。因此,下列表达式的值必定是true:

      - ```java
      ("a" + "b" + "c").intern() == "abc"
  • 通俗点讲,Interned String就是确保字符串在内存里只有一份拷贝,这样可以节约内存空间,加快字符串操作任务的执行速度。注意,这个值会被存放在字符串内部池(String Intern Pool)。

image-20210426020658942

  • 如何保证变量s指向的是字符串常量池中的数据呢?
    • 有两种方式:
      • 方式一: String s = “shkstart”;//字面量定义的方式
      • 方式二: 调用intern()
        •     String s = new String("shkstart").intern();
        •     String s = new StringBuilder("shkstart").toString().intern();
3、面试题题目: new String(“ab”) 会创建几个对象? 拓展: new String(“a”) + new String (“b”)呢?

代码:

1
2
3
4
5
6
7
public class StringNewTest {
public static void main(String[] args) {
// String str = new String("ab");

String str = new String("a") + new String("b");
}
}

题目: new String(“ab”) 会创建几个对象?

  • new String(“ab”)会创建几个对象?看字节码,就知道是两个。
    • 一个对象是:new关键字在堆空间创建的
    • 另一个对象是:字符串常量池中的对象”ab”。 字节码指令:ldc

拓展: new String(“a”) + new String (“b”)呢?

  • 看字节码,知道是五个对象
    • 对象1:new StringBuilder()
    • 对象2: new String(“a”)
    • 对象3: 常量池中的”a”
    • 对象4: new String(“b”)
    • 对象5: 常量池中的”b”
  • 深入剖析: StringBuilder的toString():
    • 对象6 :new String(“ab”)
  • 强调一下,toString()的调用(底层用的是char[]数组创建的字符串),在字符串常量池中,没有生成”ab”
4、intern()的使用: jdk6 vs jdk7/8

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 如何保证变量s指向的是字符串常量池中的数据呢?
* 有两种方式:
* 方式一: String s = "shkstart";//字面量定义的方式
* 方式二: 调用intern()
* String s = new String("shkstart").intern();
* String s = new StringBuilder("shkstart").toString().intern();
*/
public class StringIntern {
public static void main(String[] args) {
String s = new String("1");
s.intern();//调用此方法之前,字符串常量池中已经存在了"1"
String s2 = "1";
System.out.println(s == s2);//jdk6:false jdk7/8:false
String s3 = new String("1") + new String("1");//s3变量记录的地址为:new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
s3.intern();//在字符串常量池中生成"11"。如何理解:jdk6:创建了一个新的对象"11",也就有新的地址。
// jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址
String s4 = "11";//s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址
System.out.println(s3 == s4);//jdk6:false jdk7/8:true
}
}

关于s3 == s4在JDK6 与 JDK7/8中答案不同的解析:

  • JDK6:在JDK6中,字符串常量池是放在永久代中

    • 执行到String s3 = new String(“1”) + new String(“1”);的时候,程序在堆空间创建了一片空间用来存放字符串”11”,局部变量s3里存放着字符串”11”在堆空间当中的地址。

      注意:此时的字符串常量池中并不存在字符串”11”(底层调用了StringBuilder的toString()方法,其中的new String使用char[]数组的方式创建了字符串”11”)

    • 执行到s3.intern();的时候,intern方法会从字符串常量池中查询当前字符串是否存在,若不存在就会将当前字符串放入常量池中。然而字符串常量池当中并不存在字符串”11”。所以,程序在字符串常量池中生成”11”对象。

    • 执行到String s4 = “11”;的时候,由于在字符串常量池中存在了字符串”11”,所以局部变量s4里存放了字符串”11”在字符串常量池当中的引用(即地址)

    • 由于变量s3的引用是堆当中字符串对象”11”的地址,而变量s4的引用是字符串常量池中字符串”11”的地址。两者并不相等。

  • JDK7/8:在JDK7/8中,字符串常量池放在堆中

    • 执行String s3 = new String(“1”) + new String(“1”);与上面描述的一样
    • 执行到s3.intern();的时候,由于字符串常量池是存放在堆空间当中,而字符串”11”对象也在堆空间,所以intern()方法在字符串常量池中创建字符串”11”的时候,直接将堆空间中的字符串”11”的地址存放进了字符串常量池的字符串”11”对象当中,即字符串常量池中存放的是堆空间中字符串”11”的引用。即变量s3的引用也指向了字符串常量池的字符串”11”
    • 代码执行到String s4 = “11”;的时候,由于在字符串常量池中存在了字符串”11”,所以局部变量s4里存放了字符串”11”在字符串常量池当中的引用(即地址),然而字符串常量池中的字符串”11”存放的是堆空间的字符串”11”的地址。即:变量s4的引用也指向了堆空间在的字符串”11”的地址
    • 这样一来堆空间与字符串常量池存放的字符串”11”为同一个对象。因此变量s3与变量s4指向的都是同一个地址,使用两者相等。

JDK6的执行图:

image-20210426022637283

JDK7的执行图:

image-20210426022851422

对上面案例在进行扩展:

代码:将 String s4 = “11”; 与 String s5 = s3.intern(); 执行顺序进行互换,并使用s5接收返回值

1
2
3
4
5
6
7
8
9
10
11
public class StringIntern1 {
public static void main(String[] args) {
//StringIntern.java中练习的拓展:
String s3 = new String("1") + new String("1");//new String("11")
//执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!!
String s4 = "11";//在字符串常量池中生成对象"11"
String s5 = s3.intern();//intern方法会从字符串常量池中查询当前字符串是否存在,若存在就会返回该字符串的引用(地址)
System.out.println(s3 == s4);//false
System.out.println(s5 == s4);//true
}
}
5、总结String的intern()方法的使用
  • jdk1. 6中,将这个字符串对象尝试放入串池。
    • 如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
    • 如果没有,会把此对象复制一份(新建对象),放入字符串常量池,并返回字符串常量池中的对象地址
  • Jdk1.7起,将这个字符串对象尝试放入串池。
    • 如果字符串常量池中有,则并不会放入。返回已有的字符串常量池中的对象的地址
    • 如果没有,则会把对象的引用地址复制一份(没有新建对象),放入字符串常量池,并返回字符串常量池中的引用地址
6、关于intern()方法的两道练习
1、练习1:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StringExer1 {
public static void main(String[] args) {
//String x = "ab";
String s = new String("a") + new String("b");//new String("ab")
//在上一行代码执行完以后,字符串常量池中并没有"ab"

String s2 = s.intern();//jdk6中:在串池中创建一个字符串"ab"
//jdk8中:串池中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回

System.out.println(s2 == "ab");//jdk6:true jdk8:true
System.out.println(s == "ab");//jdk6:false jdk8:true
}
}

图示:

image-20210426032250932

放开 String x = “ab”; 注释之后的图示:

image-20210426032752667

2、练习2

代码:

1
2
3
4
5
6
7
8
9
public class StringExer2 {
public static void main(String[] args) {
String s1 = new String("ab");//执行完以后,会在字符串常量池和堆空间中都会生成"ab",不同对象。s1指向的是堆空间当中的字符串"abc"
// String s1 = new String("a") + new String("b");////执行完以后,在堆空间中会生成字符串"abc",但不会在字符串常量池中会生成"ab"
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2);//在jdk7:false
}
}
7、使用intern()测试执行效率:空间角度的使用上

两种方式创建字符串:

1
2
3
arr[i] = new String(String.valueOf(data[i % data.length]));

arr[i] = new String(String.valueOf(data[i % data.length])).intern();

结论:对于程序中大量存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间

应用场景:

大的网站平台,需要内存中存储大量的字符串。比如社交网站,很多人都存储:北京市、海淀区等信息。这时候如果字符串都调用intern()方法,就会明显降低内存的大小。

6、StringTable的垃圾回收

使用new String()的方式和使用new String().intern()的方式创建字符串都会在堆与字符串常量池创建字符串对象,但是为什么在存在大量重复的字符串的时候使用intern()会更节省内存空间呢? =》 答案:StringTable存在垃圾回收。

  • 使用new String()的方式创建字符串不仅仅会在字符串常量池当中创建字符串对象(不重复),还会在堆空间当中创建大量的字符串对象(存在重复),这些堆空间的字符串对象都有一个变量的引用指向,GC不会进行垃圾回收。
  • 使用new String().intern()的方式创建字符串虽然也会在堆空间和字符串常量池创建字符串对象,但是局部变量的指向的是字符串常量池的字符串对象,堆空间的字符串对象虽然也被创建了,但是没有变量的引用指向,会被GC回收。

image-20210426115438284

7、G1的String去重操作

官网

  • 背景:对许多Java应用(有大的也有小的)做的测试得出以下结果:
    • 堆存活数据集合里面String对象占了25%
    • 堆存活数据集合里面重复的String对象有13.5%
    • String对象的平均长度是45
  • 许多大规模的Java应用的瓶颈在于内存,测试表明,在这些类型的应用里面,Java堆中存活的数据集合差不多25%是String对象。更进一步,这里面差不多一半string对象是重复的,重复的意思是说:string1.equals(string2)=true。堆上存在重复的String对象必然是一种内存的浪费。这个项目将在G1垃圾收集器中实现自动持续对重复的String对象进行去重,这样就能避免浪费内存。
  • 实现
    • 当垃圾收集器工作的时候,会访问堆上存活的对象。对每一个访问的对象都会检查是否是候选的要去重的String对象
    • 如果是,把这个对象的一个引用插入到队列中等待后续的处理。一个去重的线程在后台运行,处理这个队列。处理队列的一个元素意味着从队列删除这个元素,然后尝试去重它引用的String对象。
    • 使用一个hashtable来记录所有的被String对象使用的不重复的char数组。当去重的时候,会查这个hashtable,来看堆上是否已经存在一个一模一样的char数组。
    • 如果存在,String对象会被调整引用那个数组,释放对原来的数组的引用,最终会被垃圾收集器回收掉。
    • 如果查找失败,char数组会被插入到hashtable,这样以后的时候就可以共享这个数组了
  • 命令行选项
    • UseStringDeduplication(bool):开启String去重,默认是不开启的,需要手动开启
    • PrintStringDeduplicationStatistics(bool):打印详细的去重统计信息
    • StringDeduplicationAgeThreshold(uintx):达到这个年龄的String对象被认为是去重的候选对象

14、垃圾回收概述

  • 垃圾收集,不是Java语言的伴生产物。早在1960年,第一门开始使用内存动态分配和垃圾收集技术的Lisp语言诞生。
  • 关于垃圾收集有三个经典问题:
    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?
  • 垃圾收集机制是Java的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

1、什么是垃圾

  • 什么是垃圾( Garbage) 呢
    • 垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
    • 外文: An object is considered garbage when it can no longer be reached from any pointer in the running program.
  • 如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出
  • 内存溢出:存在引用指向不再被使用的对象,导致该对象无法被回收。比如匿名内部类存在指向外部类的引用等等。

2、为什么需要GC

  • 对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。
  • 除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便JVM将整理出的内存分配给新的对象
  • 随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。

3、早期的垃圾回收

  • 在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。比如以下代
    码:

    1
    2
    3
    4
    MibBridge *pBridge = new cmBaseGroupBridge();
    //如果注册失败,使用Delete释放该对象所占内存区域
    if (pBridge->Register(kDestroy) != NO_ERROR)
    delete pBridge;
  • 这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回
    收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃

  • 在有了垃圾回收机制后,,上述代码块极有可能变成这样:

    1
    2
    MibBridge *pBridge = new cmBaseGroupBridge();
    pBridge->Register(kDestroy);
  • 现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化的内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。

4、java垃圾回收机制

  • 自动内存管理无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险

    • 没有垃圾回收器,java也会和cpp一样,各种悬垂指针,野指针,泄漏问题让你头疼不已。
  • 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

  • oracle官网关于垃圾回收的介绍

  • 对于Java开发人员而言自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化Java开发人员在程序出现内存溢出时定位问题和解决问题的能力

  • 此时,了解JVM的自动内存分配和内存回收原理就显得非常重要,只有在真正了解JVM是如何管理内存后,我们才能够在遇见OutOfMemoryError时,快速地根据错误异常日志定位问题和解决问题

  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术**实施必要的监控调节**。

  • 垃圾回收的区域:**(Heap)与方法区(Method Area)**

    image-20210426180331460

  • 垃圾回收器可以对年轻代回收,也可以对老年代回收,甚至是全堆和方法区的回收。

    • 其中,**Java堆是垃圾收集器的工作重点**。
  • 从次数上讲:

    • 频繁收集Young区
    • 较少收集Old区
    • 基本不动方法区(Perm区或元空间)

15、垃圾回收相关算法

  • 判断对象存活
    • 在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段
    • 那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
    • 判断对象存活一般有两种方式:**引用计数算法可达性分析算法**。
  • 回收垃圾
    • 当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存
    • 目前在JVM中比较常见的三种垃圾收集算法是:**标记一清除算法(Mark-Sweep)复制算法(Copying)标记-压缩算法(Mark-Compact)**。

1、标记阶段:引用计数算法

1、引用计数算法概述
  • 引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性用于记录对象被引用的情况
  • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
  • 优点:
    • 实现简单,垃圾对象便于辨识
    • 判定效率高,回收没有延迟性
  • 缺点:
    • 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
    • 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
    • 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法
2、循环引用

image-20210426203337926

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* -XX:+PrintGCDetails
* 证明:java使用的不是引用计数算法
*/
public class RefCountGC {
//这个成员属性唯一的作用就是占用一点内存
private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();

obj1.reference = obj2;
obj2.reference = obj1;

// 手动断开各自reference的引用
obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为
//这里发生GC,obj1和obj2能否被回收?能 -> java使用的不是引用计数算法
System.gc();
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

图示:

image-20210426204015295

3、小结
  • 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
  • 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
  • Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
  • Python如何解决循环引用?
    • 手动解除:很好理解,就是在合适的时机,解除引用关系。
    • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。

2、标记阶段:可达性分析算法(或根搜索算法、追踪性垃圾收集)

  • 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生

  • 相较于引用计数算法,这里的可达性分析就是Java、C#**选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集**(Tracing GarbageCollection)。

  • 所谓”GC Roots”根集合就是一组必须活跃的引用

  • 基本思路:

    • 可达性分析算法是以根对象集合(GC Roots) 为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达
    • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
    • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
    • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
  • 图示

    image-20210426210515944

  • 在Java语言中,GC Roots包括以下几类元素:

    • 虚拟机栈中引用的对象
      • 比如:各个线程被调用的方法中使用到的参数、局部变量等。
    • 本地方法栈内JNI (通常说的本地方法)引用的对象
    • 方法区中类静态属性引用的对象
      • 比如: Java类的引用类型静态变量
    • 方法区中常量引用的对象
      • 比如:字符串常量池(String Table)里的引用
    • 所有被同步锁synchronized持有的对象
    • Java虚拟机内部的引用。
      • 基本数据类型对应的Class对象一些常驻的异常对象(如:NullPointerException、OutOfMemoryError) ,系统类加载器
    • 反映java虛拟机内部情况的JMXBeanJVMTI中注册的回调本地代码缓存等。
  • 图示:

    image-20210426210824260

  • 除了这些固定的GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集局部回收(Partial GC)

    • 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。
  • 小技巧:

    • 由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
  • 注意:

    • 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
    • 这点也是导致GC进行时必须”Stop The World“的一个重要原因。
      • 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的

3、对象的finalization机制

  • Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑
  • 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize()方法
  • finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
  • 永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面三点:
    • 在finalize() 时可能会导致对象复活
    • finalize() 方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize()方法将没有执行机会。
    • 一个糟糕的finalize()会严重影响GC的性能
  • 从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。
  • 由于finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态
  • 如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件(finalize()方法)下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
    • 可触及的:从根节点开始,可以到达这个对象。
    • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()中复活。
    • 不可触及的:对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为finalize()只会被调用一次
  • 以上3种状态中,是由于finalize()方法的存在进行的区分。只有在对象不可触及时才可以被回收
  • 判定一个对象objA是否可回收,至少要经历两次标记过程,具体过程:
    • 如果对象objA到GC Roots没有引用链,则进行第一次标记
    • 进行筛选,判断此对象是否有必要执行finalize()方法
      • 如果对象objA没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
      • 如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
      • finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次

4、MAT与JProfiler的GC Roots溯源

1、MAT概述

MAT是Memory Analyzer的简称,它是一款功能强大的Java堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。

MAT是基于Eclipse开发的,是一款免费的性能分析工具。

大家可以在官网下载并使用MAT。

2、获取dump文件
  1. 命令行使用jmap

    image-20210426214539114

  2. 使用JVisualVM导出

    • 捕获的heap dump文件是一个临时文件,关闭JVisua1VM后自动删除,若要保留,需要将其另存为文件。
    • 可通过以下方法捕获heap dump:
      • 在左侧”Application”(应用程序)子窗口中右击相应的应用程序,选择Heap Dump(堆Dump)。
      • 在Monitor (监视)子标签页中点击Heap Dump (堆Dump)按钮。
    • 本地应用程序的Heap dumps作为应用程序标签页的一个子标签页打开。同时,heap dump 在左侧的Application (应用程序)栏中对应一个含有时间戳的节点。右击这个节点选择save as (另存为)即可将heap dump保存到本地。
3、使用MAT打开heap dump文件.hprof
  1. File -> Open File -> 找到对应的.hprof文件导入

  2. 导入后图示:

    image-20210426220319212

  3. 在MAT中查看GC Roots的方法

    image-20210426220439764

  4. GC Roots的相关展示:详情可查看官网

    image-20210426221159953

4、使用Profiler进行GC Roots溯源
  1. Live memory -> All Objects -> View -> Mark Current Values (查看当前对象的个数)(光标变绿)

    image-20210426223157917

    image-20210426223406880

  2. 选择其中一个占内存较多的类 -> Show Selection In Heap Walker -> References(查看当前类的相关引用)

    image-20210426223532525

    image-20210426223639192

  3. 查看哪个对象在哪里被关联(用来解决内存泄漏问题:查看内存泄漏的相关对象在哪里被引用)

    image-20210426224059101

    image-20210426224309986

5、使用Profiler分析OOM
  1. 可以在代码中使用参数:-XX: +HeapDumpOnOutOfMemoryError,当程序出现OOM的时候在当前目录下自动生成Heap Dump文件

  2. Heap Walker -> Current Object Set -> Biggest Objects查看是否存在占用内存的超大对象

    image-20210426224832591

  3. 在Thread Dump处查看哪个线程的哪个位置出现了OOM

    image-20210426225204897

5、清除阶段:标记-清除算法

  • 背景:

    • 标记一清除算法( Mark-Sweep )是一种非常基础和常见的垃圾收集算法,该算法被J.McCarthy等人在1960年提出并应用于Lisp语言。
  • 执行过程:

    堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world) ,然后进行两项工作,第一项则是标记,第二项则是清除

    • 标记:Collector从引用根节点开始遍历,标记所有被引用的对象**。一般是在对象的Header中记录为可达对象**。
    • 清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
  • 图示:

    第14章_标记-清除算法

  • 缺点:

    • 效率不算高
    • 进行GC的时候,需要停止整个应用程序,导致用户体验差
    • 这种方式清理出来的空闲内存是不连续的产生内存碎片
    • 需要维护一个空闲列表
  • 注意:何为清除(透明覆盖)

    • 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里
    • 下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

6、清除阶段:复制算法

  • 背景:

    为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky于1963年发表了著名的论文,”使用双存储区的Lisp语言垃圾收集器CALISP Garbage Collector Algorithm Using Serial Secondary Storage)”M.L.Minsky在该论文中描述的算法被人们称为复制(Copying) 算法,它也被M.L.Minsky本人成功地引入到了Lisp语言的一个实现版本中。

  • 核心思想:

    将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

  • 图示:

    第14章_复制算法

  • 优点:

    • 没有标记和清除的过程,实现简单,运行高效
    • 复制过去以后保证空间的连续性,不会出现”碎片”问题
  • 缺点:

    • 需要两倍的内存空间
    • 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
  • 特别的:

    • 如果系统中的垃圾对象很多,复制算法不会很理想。复制算法需要复制的存活对象数量要求不要太多,或者说非常少才行。
    • 特别适合垃圾对象很多,存活对象很少的场景;例如:Young区的Survivor0和Survivor1区
  • 应用场景:

    在新生代,对常规应用的垃圾回收,一次通常可以回收70% - 99%的内存空间。

    回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。

    image-20210427010211524

7、清除阶段:标记-压缩算法

  • 背景:

    • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法
    • 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以JVM的设计者需要在此基础之上进行改进。标记-压缩(Mark一Compact) 算法由此诞生。
    • 1970年前后,G.L.Steele、C.J.Chene和D.s.Wise等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。
  • 执行过程:

    • 第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象
    • 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。
    • 之后,清理边界外所有的空间。
  • 图示:

    第14章_标记-压缩算法

  • 标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark- Sweep- Compact)算法

  • 二者的本质差异在于标记-清除算法是一种非移动式的回收算法标记-压缩是移动式的是否移动回收后的存活对象是一项优缺点并存的风险决策。

    • 风险:所有引用到存活对象的引用都需要修改
  • 可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。

  • 指针碰撞:

    • 如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump the Pointer) 。
  • 优点:

    • 消除了标记-清除算法当中内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可
    • 消除了复制算法当中,内存减半的高额代价
  • 缺点:

    • 从效率上来说,标记-整理算法要低于复制算法
    • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址
    • 移动过程中,需要全程暂停用户应用程序。即: STW

8、小结

对比三种清除算法

Mark-Sweep Mark-Compact Copying
速度 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不会堆积碎片) 通常需要活对象的两倍大小(不堆积碎片)
移动对象
再分配对象空间使用 空闲列表 指针碰撞 指针碰撞

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。

9、分代收集算法

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的GC都是采用分代收集( Generational Collecting) 算法执行垃圾回收的

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代(Young Gen)
    • 年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁
    • 这种情况复制算法的回收整理,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过hotspot中的两个survivor的设计得到缓解
  • 老年代(Tenured Gen)
    • 老年代特点:区域较大,对象生命周期长、存活率高,回收不及年轻代频繁
    • 这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记-清除或者是标记-清除与标记-整理的混合实现
      • Mark阶段的开销与存活对象的数量成正比
      • Sweep阶段的开销与所管理区域的大小成正比
      • Compact阶段的开销与存活对象的数量成正比

以HotSpot中的CMS回收器为例,CMS是基于Mark- Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳(碎片导致的Concurrent Mode Failure时),将采用Serial Old执行Full GC以达到对老年代内存的整理

分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

10、增量收集算法、分区算法

上述现有的算法,在垃圾回收过程中,应用软件将处于一种Stop the World的状态。在Stop the World(SWT)状态下,应用程序所有的线程都会挂起,暂停切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集( Incremental Collecting) 算法的诞生。

基本思想:

  • 如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。(并发思想
  • 总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作

优点

  • 使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间

缺点

  • 但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降

分区算法

  • 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,有关GC产生的停顿也越长

  • 为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

  • 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间region。

  • 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间

  • 图示:

    image-20210427023448226

    第14章_分区算法

注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。


16、垃圾回收相关概念

1、System.gc()的理解

  • 在目录情况下,通过System.gc()或者runtime.getRuntime().gc()的调用,会显式触发Full GC同时对老年代和新时代进行回收,尝试释放被丢弃对象占用的内存。
  • 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。
  • JVM实现者可以通过System.gc()调用来觉得JVM的GC行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()。

System.gc()提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc,但是调用System.runFinalization()方法可以强制调用使用引用的对象的finalize()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SystemGCTest {
public static void main(String[] args) {
new SystemGCTest();
System.gc();//提醒jvm的垃圾回收器执行gc,但是不确定是否马上执行gc
//与Runtime.getRuntime().gc();的作用一样
System.runFinalization();//强制调用使用引用的对象的finalize()方法
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("SystemGCTest 重写了finalize()");
}
}

System.gc()的相关案例:

image-20210427112323264

问题:调用System.gc()无法保证对垃圾收集器的调用,为什么上述案例中,每次调用都会有垃圾回收信息输出?是进行了GC吗?

2、内存溢出(OOM)与内存泄漏(Memory Leak)

1、内存溢出(OOM)
  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

  • 由于GC一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现OOM的情况。

  • 大多数情况下,GC会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的Full GC操作,这时候会回收大量的内存供应用程序继续使用。

  • javadoc中对OutOfMemoryError的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存

  • 首先说没有空闲内存的情况:说明Java虛拟机的堆内存不够。原因有二:

    1. Java虚拟机的堆内存设置不够

      比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms、-Xmx来调整

    2. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

      对于老版本的Oracle JDK, 因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern字符串缓存占用太多空间,也会导致OOM问题。对应的异常信息,会标记出来和永久代相关:”java.lang.OutOfMemoryError: PermGen space”。

      随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM的异常信息则变成了:”java.lang.OutOfMemoryError: Metaspace”。 直接内存不足,也会导致OOM。

  • 这里面隐含着一层意思是,在抛出OutOfMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

    • 例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
    • 在java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。
  • 当然,也不是在任何情况下垃圾收集器都会被触发的

    • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接拋出OutOfMemoryError。
2、内存泄漏(Memory Leak)
  • 也称作”存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏

  • 但实际情况很多时候一些不太好的实践 (或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做**宽泛意义上的”内存泄漏”**。

  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现OutOfMemory异常,导致程序崩溃。

  • 注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小

  • 图示:

    image-20210427230217504

  • 举例:

    1. 单例模式:

      单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

    2. 一些提供close的资源未关闭导致内存泄漏:

      数据库连接(dataSourse . getConnection()),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

3、Stop The World

  • Stop-The-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
    • 可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿
      • 分析工作必须在一个能确保一致性的快照中进行
      • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
      • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证
  • 被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少STW的发生。
  • STW事件和采用哪款GC无关,所有的GC都有STW这个事件
  • 哪怕是G1也不能完全避免Stop-the-world情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
  • STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。
  • 开发中不要用System.gc();会导致Stop-the-world的发生

4、垃圾回收的并行与并发

1、并发(Concurrent)
  • 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
  • 并发不是真正意义上的”同时进行”,只是CPU把一个时间段分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于CPU处理的速度非常快,主要时间间隔处理得当,即可让用户感觉是多个应用程序在同时运行。

image-20210427231222179

2、并行(Parallel)
  • 当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,我们称之为并行(Parallel)。
  • 其实决定并行的因素不是CPU的数量,而是CPU的核心数量,比如一个CPU多个核也可以并行。
  • 适合科学计算,后台处理等弱交互场景

image-20210427231302158

3、并行 VS 并发

二者对比:

  • 并发,指的是多个事情,在同一时间段内同时发生了
  • 并行,指的是多个事情,在同一时间点上同时发生了
  • 并发的多个任务之间是互相抢占资源的。
  • 并行的多个任务之间是不互相抢占资源的。
  • 只有在多CPU或者一个CPU多核的情况中,才会发生并行
  • 否则,看似同时发生的事情,其实都是并发执行的
4、垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

    • 如并行的垃圾回收器:ParNew、 Parallel Scavenge、 Parallel Old;
  • 串行(Serial)

    • 相较于并行的概念,单线程执行
    • 如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

    图示:

    image-20210427231437839

  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行

    • 用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上
    • 如: CMS、G1

    图示:

    image-20210427231919364

5、安全点与安全区域

1、安全点(Safepoint)

程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为”安全点(Safepoint)”

Safe Point的选择很重要,如果太少可能导致GC等待的时间太长如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。

比如:选择些执行时间较长的指令作为Safe Point,如方法调用循环跳转异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  • 抢先式中断:(目前没有虚拟机采用了)

    首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。

  • 主动式中断

    设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

2、安全区域

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint 。但是,程序“不执行”的时候呢?

例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应JVM 的中断请求,”走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把 Safe Region 看做是被扩展了的Safepoint。

实际执行时:

  1. 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程;
  2. 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;

6、在谈引用

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

[既偏门非常高频的面试题]强引用、软引用、弱引用、虚引用有什么区别? 具体使用场景是什么?

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(StrongReference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference) 4种,这4种引用强度依次逐渐减弱。(强软弱虚)

强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

image-20210427233208635

Reference子类中只有终结器引用是包内可见的,其他3种引用类型均为public,可以在应用程序中直接使用

  • 强引用(StrongReference)**:最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。(不回收**)
  • 软引用(SoftReference)**:在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。(内存不足即回收**)
  • 弱引用(WeakReference)**:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。(发现即回收**)
  • 虚引用(PhantomReference)**:一个对象是否有虛引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。(对象回收的跟踪**)

7、再谈引用:强引用Strong Reference(不回收)

在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型

当在Java语言中使用new操作符创建一个新的对象, 并将其赋值给一个变量的时候, 这个变量就成为指向该对象的一个强引用。

强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。

相对的,软引用、 弱引用和虚引用的对象是软可触及、弱可触及和虛可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一

强引用例子:

1
StringBuffer str = new StringBuffer ("Hello");

局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是StringBuffer实例的强引用

对应内存结构:image-20210428093616812

此时,如果再运行一个赋值语句:

1
StringBuffer str1 = str;

对应内存结构:image-20210428093737403

本例中的两个引用,都是强引用,强引用具备以下特点:

  • 强引用可以直接访问目标对象
  • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象
  • 强引用可能导致内存泄漏

8、再谈引用:软引用Soft Reference(内存不足即回收)

软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。实例:Mybatis的一些内部类中就使用了软引用

垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(Reference Queue) 。

类似弱引用,只不过Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

在java doc中,软引用是这样描述的:

虚拟机在抛出 OutOfMemoryError 之前会保证所有的软引用对象已被清除。此外,没有任何约束保证软引用将在某个特定的时间点被清除,或者确定一组不同的软引用对象被清除的顺序。不过,虚拟机的具体实现会倾向于不清除最近创建或最近使用过的软引用

软引用在我们的日常开发中使用的场景很多,比如商城中商品的信息。某个商品可能会被多人访问,此时我们可以把该商品的信息使用软引用保存。当系统内存足够时,可以实现高速查找,当系统内存不足又会被回收,避免OOM的风险。

注意:

尽管软引用会在OOM之前被清理,但是,这并不表示Full GC会清理软引用对象。在经过Full GC后我们的软引用对象都放入了old区,由于Full GC的存在,程序大多数情况下并不会OOM。由于软引用对象占据了老年代的空间,Full GC将执行的更为频繁。所以还是建议使用弱引用。

当然,我们可以通过参数:-XX:SoftRefLRUPolicyMSPerMB=0来设置当Full GC时回收软引用。其中参数值为Full GC保留的 SoftReference 数量,参数值越大,GC 后保留的软引用对象就越多。设置这个参数值为0时,Full GC就会回收我们的软引用对象了。

在JDK 1.2版之后提供了java.lang.ref.SoftReference类来实现软引用。

1
2
3
Object obj = new Object(); //声明强引用
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; //销毁强引用

9、再谈引用:弱引用Weak Reference(发现即回收)

弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间使用是否充足,都会回收掉只被弱引用关联的对象。

但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

实例:

  • 三级缓存(内存(弱引用) -> 本地 -> 网络)
  • ThreadLocal的内部实现就是一个ThreadLocalMap,该mapEntrykeyThreadLocal本身,value为我们向ThreadLocal对象set的值,其中的key就是弱引用对象
  • 集合WeakHashMap,都是使用了弱引用实现的

在JDK 1.2版之后提供了java.lang.ref.WeakReference类来实现弱引用。

1
2
3
Object obj = new Object(); //声明强引用
WeakReference<Object> wr = new WeakReference<Object>(obj);
obj = null; //销毁强引用

弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收弱引用对象更容易、更快被GC回收

面试题:你开发中使用过WeakHashMap吗?

10、再谈引用:虚引用Phantom Reference(对象回收跟踪)

也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。

不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法取得对象时,总是null

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录

在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

1
2
3
4
Object obj = new Object();
ReferenceQueue phantomQueue = new ReferenceQueue( );
PhantomReference<object> pf = new PhantomReference<object>(obj, phantomQueue);
obj = null;

对象回收跟踪的代码实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

/**
* 虚引用的测试
*/
public class PhantomReferenceTest {
public static PhantomReferenceTest obj;//当前类对象的声明
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;//引用队列

// 守护线程,用来跟踪对象的回收
public static class CheckRefQueue extends Thread {
@Override
public void run() {
while (true) {
if (phantomQueue != null) {
PhantomReference<PhantomReferenceTest> objt = null;
try {
objt = (PhantomReference<PhantomReferenceTest>) phantomQueue.remove();
} catch (InterruptedException e) {
e.printStackTrace();
}
if (objt != null) {
System.out.println("追踪垃圾回收过程:PhantomReferenceTest实例被GC了");
}
}
}
}
}

@Override
protected void finalize() throws Throwable { //finalize()方法只能被调用一次!
super.finalize();
System.out.println("调用当前类的finalize()方法");
obj = this;
}

public static void main(String[] args) {
Thread t = new CheckRefQueue();
t.setDaemon(true);//设置为守护线程:当程序中没有非守护线程时,守护线程也就执行结束。
t.start();

phantomQueue = new ReferenceQueue<PhantomReferenceTest>();
obj = new PhantomReferenceTest();
//构造了 PhantomReferenceTest 对象的虚引用,并指定了引用队列
PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<PhantomReferenceTest>(obj, phantomQueue);

try {
//不可获取虚引用中的对象
System.out.println(phantomRef.get());
//将强引用去除
obj = null;
//第一次进行GC,由于对象可复活,GC无法回收该对象
System.gc();
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
System.out.println("第 2 次 gc");
obj = null;
System.gc(); //一旦将obj对象回收,就会将此虚引用存放到引用队列中。
Thread.sleep(1000);
if (obj == null) {
System.out.println("obj 是 null");
} else {
System.out.println("obj 可用");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

11、再谈引用:终结器引用Final reference

  • 它用以实现对象的finalize()方法,也可以称为终结器引用
  • 无需手动编码,其内部配合引用队列使用。
  • 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象

17、垃圾回收器

1、GC分类与性能指标

1、垃圾回收器概述
  • 垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
  • 由于JDK的版本处于高速迭代过程中,因此Java发展至今已经衍生了众多的GC版本。
  • 从不同角度分析垃圾收集器,可以将GC分为不同的类型。
2、垃圾回收器分类
1、按线程数分,可以分为串行垃圾回收器并行垃圾回收器

image-20210428111017627

  • 串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
    • 在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中
    • 在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。
  • 和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“Stop-the-world”机制。
2、按照工作模式分,可以分为并发式垃圾回收器独占式垃圾回收器
  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(Stop the world)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

image-20210428111338892

3、按碎片处理方式分,可分为压缩式垃圾回收器非压缩式垃圾回收器
  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
    • 再分配对象空间使用:指针碰撞
  • 非压缩式的垃圾回收器不进行这步操作。
    • 再分配对象空间使用:空闲列表
4、按工作的内存区间分,又可分为年轻代垃圾回收器老年代垃圾回收器
3、评估GC的性能指标
  • 吞吐量:运行用户代码的时间占总运行时间的比例
    • (总运行时间:程序的运行时间十内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
  • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java堆区所占的内存大小。
  • 快速:一个对象从诞生到被回收所经历的时间。

关于吞吐量、暂停时间与内存占用:

  • 这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好。一款优秀的收集器通常最多同时满足其中的两项。
  • 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多些越来越能容忍,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。
  • 简单来说,主要抓住两点:
    • 吞吐量
    • 暂停时间
1、吞吐量(throughput)
  • 吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间+垃圾收集时间)

    • 比如:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
  • 这种情况下,应用程序能容忍较高的暂停时间,因此,高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的。

  • 吞吐量优先,意味着在单位时间内,STW的时间最短:0.2 + 0.2 = 0.4

    image-20210428111925067

2、暂停时间(pause time)
  • “暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态

    • 例如,GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。
  • 暂停时间优先,意味着尽可能让单次STW的时间最短: 0.1 + 0.1 + 0.1 + 0.1+0.1=0.5

    image-20210428112108819

3、吞吐量 VS 暂停时间
  • 高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。
  • 低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序
  • 不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的目标(矛盾)。
    • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收
    • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这又引起了年轻代内存的缩减和导致程序吞吐量的下降
  • 在设计(或使用) GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折衷。
  • 现在标准:在最大吞吐量优先的情况下,降低停顿时间

2、不同的垃圾回收器概述

1、垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是Garbage Collection, 对应的产品我们称为Garbage Collector(GC)。

  • 1999年随JDK1.3.1一起来的是串行方式的Serial GC,它是第一款GC。ParNew垃圾收集器是Serial收集器的多线程版本
  • 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK1.4.2一起发布
  • Parallel GC在JDK6之后成为HotSpot默认GC。
  • 2012年,在JDK1.7u4版本中,G1可用。
  • 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
  • 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  • —–分水岭——
  • 2018年9月,JDK11发布。引入Epsilon垃圾回收器,又被称为”No-Op (无操作) “回收器。同时引入ZGC:可伸缩的低延迟垃圾回收器(Experimental)。
  • 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入Shenandoah GC:低停顿时间的GC (Experimental)。
  • 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
  • 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在macOS和Windows上的应用
2、七款经典的垃圾收集器
  • 串行回收器: Serial、Serial Old
  • 并行回收器: ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器: CMS、G1

image-20210429023423991

七款经典收集器与垃圾分代之间的关系

  • 新生代收集器: Serial、ParNew、Parallel Scavenge;
  • 老年代收集器:Serial Old、Parallel Old、CMS;
  • 整堆收集器:G1;

image-20210429024547757

垃圾收集器的组合关系

image-20210429023609239

  1. 两个收集器间有连线,表明它们可以搭配使用:

    Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。

  4. (绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)

  5. (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)

为什么要有很多收集器,一个不够吗?

因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器

如何查看默认的垃圾收集器

  • XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令:jinfo -flag 相关垃圾回收器参数进程ID

3、Serial回收器:串行回收

  • Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1. 3之前回收新生代唯一的选择。

  • Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器

  • Serial收集器采用复制算法串行回收和**”Stop-the-World”机制**的方式执行内存回收。

  • 除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和”Stop the World”机制, 只不过内存回收算法使用的是标记-压缩算法

    • Serial Old是运行在Client模式下默认的老年代的垃圾回收器
    • Serial Old在Server模式下主要有两个用途:
      • 与新生代的Parallel Scavenge配合使用
      • 作为老年代CMS收集器的后备垃圾收集方案
  • 这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World) 。

    image-20210429085028563

  • 优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

    • 运行在Client模式下的虚拟机是个不错的选择。
  • 用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms) , 只要不频繁发生,使用串行回收器是可以接受的。

  • 在HotSpot虚拟机中,使用-XX: +UseSerialGC参数可以指定年轻代和老年代都使用串行收集器

    • 等价于新生代用Serial GC, 且老年代用Serial Old GC
  • 总结:

    • 这种垃圾收集器大家了解,现在已经不用串行的了。而且在限定单核cpu才可以用。现在都不是单核的了。
    • 对于交互较强的应用而言,这种垃圾收集器是不能接受的。一般在Java web应用程序中是不会采用串行垃圾收集器的。

4、ParNew回收器:并行回收

  • 如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本

    • Par是Parallel的缩写,New:只能处理的是新生代
  • ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、”Stop-the-World”机制

  • ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器

  • 对于新生代,回收次数频繁,使用并行方式高效。

  • 对于老年代,回收次数少,使用串行方式节省资源。 (CPU并行需要切换线程,串行可以省去切换线程的资源)

    image-20210429085347499

  • 由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?

    • ParNew
      收集器运行在多CPU的环境下,由于可以充分利用多CPU、多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
    • 但是在单个CPU的环境下,ParNew收集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地
      做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  • 因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作

  • 在程序中,开发人员可以通过选项”-XX: +UseParNewGC“手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影
    响老年代

  • -XX: ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数。(一般不超过CPU的数据)

5、Parallel回收器:吞吐量优先

  • HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法并行回收和**”Stop the World”机制**。

  • 那么Parallel收集器的出现是否多此一举?

    • 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达至一个可控制的吞吐量(Throughput)**,它也被称为吞吐量优先的垃圾收集器**。
    • 自适应调节策略也是Parallel Scavenge 与ParNew一个重要区别。
  • 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序

  • Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的Parallel Old收集器,用来代替老年代的Serial Old收集器

  • Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和**”Stop-the-World”机制**。

  • 在程序吞吐量优先的应用场景中,Parallel收集器和Parallel Old收集器的组合,在Server模式下的内存回收性能很不错。

  • 在Java8中,默认是此垃圾收集器

    image-20210429090344976

  • 参数配置:

    • -XX: +UseParallelGC手动指定年轻代使用Parallel并行收集器执行内存回收任务
    • -XX: +UseParallelOldGC手动指定老年代都是使用并行回收收集器
      • 分别适用于新生代和老年代。默认jdk8是开启的
      • 上面两个参数,默认开启一个,另一个也会被开启。 (互相激活)
    • -XX: parallelGCThreads设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能
      • 在默认情况下,当CPU数量小于8个, ParallelGCThreads的值等于CPU数量。
      • 当CPU数量大于8个,ParallelGCThreads 的值等于3+[5 * CPU_Count] / 8]
    • -XX:MaxGCPaulseMillis设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒
      • 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
      • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
      • 该参数使用需谨慎
    • -XX: GCTimeRatio垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小
    • 取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1%。
    • 与前一个-XX :MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
    • -XX: +UseAdaptiveSizePolilcy:设置Parallel Scavenge收集器具有自适应调节策略
      • 在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
      • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCT imeRatio)和停顿时间
        (MaxGCPauseMills),让虚拟机自己完成调优工作。

6、CMS回收器:低延迟

  • 在JDK 1.5时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS (Concurrent -Mark -Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作

  • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

    • 目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。
    • CMS收集器就非常符合这类应用的需求。
  • CMS的垃圾收集算法采用标记-清除算法,并且也会**”Stop-the-world”**

  • 不幸的是,CMS作为老年代的收集器,却无法与JDK 1.4.0中已经存在的新生代收集器Parallel Scavenge 配合工作,所以在JDK 1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。

  • CMS的工作原理:

    image-20210429095659541

  • CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段并发标记阶段重新标记阶段并发清除阶段

    • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为Stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出GCRoots能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快
    • 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
    • 重新标记(Remark) 阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
    • 并发清除(Concurrent-Sweep) 阶段:此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 尽管CMS收集器采用的是并发回收(非独占式)**,但是在其初始化标记和再次标记这两个阶段中仍然需要执行“Stop-the-World”机制**暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-the-World”,只是尽可能地缩短暂停时间。

  • 由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的

  • 另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure“失败,这时虛拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

  • CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配

  • 既然Mark Sweep会造成内存碎片,那么为什么不把算法换成Mark Compact呢?

    • 答案其实很简答,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?
    • 要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“Stop the World”这种场景下使用
  • CMS的优点:

    • 并发收集
    • 低延迟
  • CMS的弊端:

    1. 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
    2. CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
    3. CMS收集器无法处理浮动垃圾。可能出现”Concurrent Mode Failure“失败而导致另一次Full GC的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
  • CMS收集器可以设置的参数:

    • -XX: +UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收任务
      • 开启该参数后会自动将-XX: +UseParNewGC打开。 即:ParNew (Young区用) +CMS (Old区用) + Serial Old的组合。
    • -XX:CMSlnitiatingOccupancyFraction设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收
      • JDK5及以前版本的默认值为68**,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及 以上版本默认值为92%**
      • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数
    • -XX: +UseCMSCompactAtFullCollection用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
    • -XX: CMSFullGCsBeforeCompaction设置在执行多少次Full GC后对内存空间进行压缩整理
    • -XX:Parallel CMSThreads设置CMS的线程数量
    • CMS默认启动的线程数是(ParallelGCThreads+3) / 4ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMs收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。
  • 小结:

    • HotSpot有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC这三个GC有什么不同呢?
    • 请记住以下口令:
      • 如果你想要最小化地使用内存和并行开销,请选Serial GC;
      • 如果你想要最大化应用程序的吞吐量,请选Parallel GC;
      • 如果你想要最小化GC的中断或停顿时间,请选CMS GC
  • JDK后续版本中CMS的变化

    • JDK9新特性:CMS被标记为Deprecate了(JEP291)

      • 如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃
    • JDK14新特性:删除CMS垃圾回收器(JEP363)

      • 移除了CMS垃圾收集器,如果在JDK14中使用-XX: +UseConcMarkSweepGC的话,JVM不会报错,只是给出一个warning信息,但是不会exitJVM会自动回退以默认GC方式启动JVM

        1
        OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGC;support was removed in 14. 0 and the VM will continue execution using the default collector.

7、G1回收器:区域化分代式

1、两个问题

问题1:既然我们已经有了前面几个强大的GC,为什么还要发布Garbage First (G1)GC?

  • 原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。G1 (Garbage-First) 垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。
  • 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time) ,同时兼顾良好的吞吐量。
  • 官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起”全功能收集器”的重任与期望。

问题2:为什么名字叫做Garbage First (G1) 呢?

  • 因为G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region) (物理上不连续的)。使用不同的Region来表示Eden、 幸存者0区,幸存者1区,老年代等。
  • G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region
  • 由于这种方式的侧重点在于回收垃圾最大量的区间(Region) ,所以我们给G1一个名字:垃圾优先(Garbage First)
2、G1概述

G1 (Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。

在JDK1.7版本正式启用,移除了Experimental的标识,是JDK 9以后的默认垃圾回收器,取代了CMS回收器以及Parallel + Parallel Old组合。被Oracle官方称为“全功能的垃圾收集器”。

与此同时,CMS已经在JDK 9中被标记为废弃(deprecated) 。在jdk8中还不是默认的垃圾回收器,需要使用-XX: +UseG1GC来启用。

3、G1的特点(优势)与缺点

与其他GC收集器相比,G1使用了全新的分区算法,其特点有如下四点:

  • 并行与并发

    • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
    • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会出现在整个回收阶段发生完全阻塞应用程序的情况
  • 分代收集

    • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。

    • 将堆空间分为若千个区域(Region) ,这些区域中包含了逻辑上的年轻代和老年代

    • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代

      image-20210429235305962

  • 空间整合

    • CMS:“标记-清除”算法、内存碎片、若干次GC后进行一次碎片整理
    • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact )算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显
  • 可预测的停顿时间模型(即:软实时soft real-time)

    这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。(其中吞吐量 = M-N / M)

    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集
      时间,优先回收价值最大的Region。保证了G1 收集器在有限的时间内可以获取尽可能高的收集效率
    • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

G1收集器的缺点:

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(Overload) 都要比CMS要高。
  • 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势平衡点在6-8GB之间
4、G1回收器的参数设置
  • -XX: +UseG1GC**:手动指定使用G1收集器执行内存回收任务**。
  • -XX: G1HeapRegionSize**:设置每个Region的大小**。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/ 2000。
  • -XX: MaxGCPauseMillis**:设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms**
  • -XX: ParallelGCThread设置STW时GC线程数的值。最多设置为8
  • -XX: ConcGCThreads设置并发标记的线程数。将n设置为并行垃圾回收线数(ParallelGCThreads)的1/4左右
  • -XX: InitiatingHeapoccupancyPercent设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默值是45。堆空间已用占比达到45%,老年代才会并发标记
5、G1回收器的常见操作步骤
  • G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:
    1. 第一步:开启G1垃圾收集器
    2. 第二步:设置堆的最大内存
    3. 第三步:设置最大的停顿时间
  • G1中提供了三种垃圾回收模式:YoungGC、 Mixed GC和Full GC,在不同的条件下被触发
6、G1回收器的适用场景
  • 面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)
  • 最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;
  • 如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次GC停顿时间不会过长)。
  • 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:
    1. 超过50%的Java堆被活动数据占用
    2. 对象分配频率或年代提升频率变化很大
    3. GC停顿时间过长(长于0.5至1秒)
  • HotSpot垃圾收集器里,除了G1以外,其他的垃圾收集器使用内置的JVM线程执行GC的多线程操作,而G1 GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程
7、分区Region:化整为零

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到32MB
之间,且为2的N次幂,即1MB, 2MB, 4MB, 8MB, 16MB, 32MB。可以通过-XX:G1HeapRegionSize设定。所有的Region大小相同,且在JVM生命周期内
不会被改变

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region (不需要连续)的集合。通过Region的动态分配方式实
现逻辑上的连续

  • 如果设置了Region数量,那么Region大小就不是固定的,但是大小肯定是2的幂次方,并且在1-32M之间
  • 如果设置了Region大小,那么Region数量就不是固定的,但是肯定是2048附近

Region只能是Eden、Survivor、 Humongous中的一种,但是它的身份不是固定的,谁来占用那么这个Region就是谁的

image-20210430001836775

  • 一个region有可能属于Eden,Survivor 或者Old/Tenured 内存区域。但是一个region只可能属于一个角色。图中的E表示该region属于Eden内存区域,s表示属于Survivor内存区域,O表示属于Old内存区域。图中空白的表示未使用的内存空间。
  • G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过0.5个region,就放到H。region是可以连续分配的,小于0.5的就用两个连续的region分配
  • object that is more than half a region size is considered a “Humongous object” :大于区域大小一半的对象都被视为“巨大对象”

设置Humongous的原因:

对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待

关于指针碰撞与空闲列表:

  • Bump:单个Region使用指针碰撞的方式来放数据上面allocated是已经使用的内存空间,top就是指针的位置,unallocate是没有使用的内存空间

    • Bump-the-pointer,即:指针碰撞

    image-20210430002300492

  • TLAB:虽然存在分区Region,但是依然有线程独有的TLAB空间,这样可以保证多个线程对对象修改可以并行操作

    • TLAB,即:空闲列表
8、G1回收器垃圾回收过程

G1 GC的垃圾回收过程主要包括如下三个环节:

  • 年轻代GC (Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • (如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)

顺时针,Young GC -> Young GC +Concurrent Marking -> Mixed GC顺序,进行垃圾回收。

image-20210429185519798

  • 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及
  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
  • 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的
  • 举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45号%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

G1回收器垃圾回收过程:Remembered Set

  • 一个对象被不同区域引用的问题

  • 一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?

  • 在其他的分代收集器,也存在这样的问题(而G1更突出)

  • 回收新生代也不得不同时扫描老年代?

    • 这样的话会降低Minor GC的效率;
  • 解决方法:

    • 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描
      • 每个Region都有一个对应的Remembered Set;
      • 每次Reference类型数据写操作时,都会产生一个Write Barrier 暂时中断操作;
      • 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);
      • 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;
        • 卡表是记忆集的一种具体实现方式。 见《深入理解Java虚拟机》
      • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏;

    image-20210430003324501

  • 上面提到的Remebered Set就是上述Reset,上面提到的Reference类型就是引用类型,其中Reset的作用是记录当前Region中哪些对象被外部引用指向,比如OId区中的对象会指向Eden区的对象,然后当我们要回收某个Region的时候,直接遍历遍历当前Region中的所有对象就可以了,然后针对性的去找到那些指向当前对象的其他对象,最终发现当前对象是否是根可达的,如果不是,那就应该被删除,其实之前的垃圾回收器都涉及到这个问题,当进行Minor GC的时候,通过GC Roots查找的时候还需要遍历Old区的对象,毕竟Old区对象也可能会指向Eden区对象,但是G1通过Rset避免了全堆的扫描,当引用类型数据写操作时,先暂时中断,然后判断当前引用类型数据是否被其他对象所指向,如果不被指向,那就直接放在Region中就可以了;如果被其他对象指向,那么还要判断这个对象是在当前要插入的Region中,还是在其他Region中;如果在其他Region中,那就需要使用CardTable把当前引用类型数据的指向信息放在Rset中,也就是形成上面的虚线连线,如果在当前Region中,那就不需要指向了,毕竟到时候我们会进行遍历查找根可达对象,那肯定会找到的,所以这种情况也是直接放在Region中就可以了。

9、G1回收过程一:年轻代GC

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收Eden区和Survivor区

首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

image-20210430003008242

然后开始如下回收过程:

  1. 第一阶段,扫描根。可以体现Rset作用:避免全堆扫描

    • 根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。
  2. 第二阶段,更新RSet。 作用:保证Rset中的数据准确性

    • 处理dirty card queue中的card,更新RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用

    • 对dirty card queue (脏卡表队列)的解释:

      • 对于应用程序的引用赋值语句object.field=object,JVM会在之前和之后执行特殊的操作以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中所有的card进行处理,以更新RSet,保证RSet实时准确的反映引用关系。
        • 其中object.field=object中的第一个object代表老年代中的对象,而第二个object代表Eden区中的对象
      • 那为什么不在引用赋值语句处直接更新RSet呢?
        • 这是为了性能的需要,RSet的处理需要线程同步,开销会很大,使用队列性能会好很多。
    • 脏卡表队列作用:

      Reset更新需要线程同步,所以开销会很大,因此不能实时更新,因此我们需要把引用对象被其他对象引用的关系放在一个脏卡表队列中,当年轻代回收的时候会进行STW,所以我们也正好把脏卡表队列中的值更新到Rset中,这样不仅没有涉及到开销问题,还可以保证Rset中的数据是准确的。

  3. 第三阶段,处理RSet。作用:根可达性遍历的一部分

    • 识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。
  4. 第四阶段,复制对象。说明:新生代使用复制算法

    • 此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。
  5. 第五阶段,处理引用。空Eden:Eden变成空的,那它就变成了无主Region,因此会被记录到空链表中,等待下一次被分配

    • 处理Soft,Weak,Phantom, Final, JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。
    • 以上回收的都是强引用对象,下面回收软引用对象 (不足回收)、弱引用对象(发现回收)、虚引用对象
10、G1回收过程二:并发标记过程
  1. 初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC
  2. 根区域扫描(Root Region Scanning)**:G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在Young GC之前完成。主要扫描哪些老年代对象是可达的**,毕竟我们进行Young GC的时候会移动Survivor区,移动之后就找不到哪些老年代对象是可达的了
  3. 并发标记(Concurrent Marking)**:在整个堆中进行并发标记(和应用程序并发执行),此过程可能被Young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收**(实时回收)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  4. **再次标记(Remark)**:由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。
    • 原因:并发标记不准确
  5. 独占清理(cleanup ,STW)**:计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的其实是一个统计计算过程,不会涉及垃圾清理**
    • 这个阶段并不会实际上去做垃圾的收集
  6. 并发清理阶段:识别并清理完全空闲的区域。
    • 并发清理阶段任务:如果发现区域对象中的所有对象都是垃圾,那么这个区域会被立即回收
11、G1回收过程三:混合回收

当越来越多的对象晋升到老年代Old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。 这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC

image-20210429194133565

  • 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX: G1MixedGCCountTarget设置)被回收。
  • 混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
  • 由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。垃圾占比越多, 回收优先级越高;如果垃圾不足Region空间的65%,那么将不会进行回收。
  • 混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。如果垃圾不足Region内存的10%,那么将不会对该老年代Region进行回收,综合上面的来看,只要垃圾占整个老年代Region的比例大于65%,才会对该Region进行回收
12、G1回收可选的过程四:Full GC
  • G1的初衷就是要避免Fu1l GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop- The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

  • 要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?

    • 比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。
  • 导致G1Full GC的原因可能有三个:

    1. Evacuation的时候没有足够的to-space来存放晋升的对象;
    • 解决:加大堆空间
    1. 并发处理过程完成之前空间耗尽。
    • 解决:调小触发并发GC周期的Java堆占用阈值(默认是45%, 在前面参数页有)
    1. 最大GC暂停时间太短,导致在规定的时间间隔内无法完成垃圾回收,也会导致Full GC
    • 解决:加大最大GC停顿时间
13、G1回收过程:补充

从Oracle官方透露出来的信息可获知,回收阶段( Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region, 停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

14、G1回收器优化建议
  • 年轻代大小
    • 避免使用-Xmn-XX:NewRatio等相关选项显式设置年轻代大小
    • 固定年轻代的大小会覆盖暂停时间目标
      • 原因:年轻代GC是并行独占式的,所以最好让垃圾回收器自己去调节
  • 暂停时间目标不要太过严苛
    • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间
    • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。
    • 说明:暂停时间和吞吐量是此消彼长的,所以不要把暂停时间设置的太严格,不然因为这个原因引起Full GC也不太好

8、垃圾回收总结

截止JDK 1.8,一共有7款不同的垃圾收集器。每一款不同的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于新生代 复制算法 响应速度优先 适用于单CPU
ParNew 并行运行 作用于新生代 复制算法 响应速度优先 多CPU环境Server模式下与CMS配合使用
Parallel 并行运行 作用于新生代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单CPU环境下的Client模式
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或B/S业务
G1 并发、并行运行 作用于新生代、老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用

GC发展阶段:Serial => Parallel (并行) => CMS (并发) => G1 => ZGC

怎么选择垃圾回收器?

  • Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。
  • 怎么选择垃圾收集器?
    1. 优先调整堆的大小让JVM自适应完成。
    2. 如果内存小于100M, 使用串行收集器
    3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
    4. 如果是多CPU、需要高吞吐量允许停顿时间超过1秒,选择并行或者JVM自己选择.
    5. 如果是多CPU追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。
    6. 最后需要明确一个观点:
      1. 没有最好的收集器,更没有万能的收集器;
      2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
  • 面试:
    • 对于垃圾收集,面试官可以循序渐进从理论、实践各种角度深入,也未必是要求面试者什么都懂。但如果你懂得原理,一定会成为面试中的加分项。
      这里较通用、基础性的部分如下:
      • 垃圾收集的算法有哪些?
      • 如何判断一个对象是否可以回收?
      • 垃圾收集器工作的基本流程。
    • 另外,大家需要多关注垃圾回收器这一章的各种常用的参数。

9、GC日志分析

通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。

  • 内存分配与垃圾回收的参数列表

    • -XX: +PrintGC:输出Gc日志。类似: -verbose :gc
    • -XX: +PrintGCDetails:输出GC的详细日志
    • -XX: +PrintGCTimeStamjps:输出GC的时间戳(以基准时间的形式)
    • -XX: +PrintGCDateStamps:输出GC的时间戳(以日期的形式,如2013-05-04T21 :53:59.234+0800)
    • -XX: + PrintHeapAtGC :在进行GC的前后打印出堆的信息
    • -Xloggc: ../logs/gc.log:日志文件的输出路径
  • 打开GC日志:

    1
    -verbose: gc
  • 这个只会显示总的GC堆的变化, 如下:

    1
    2
    3
    [GC (Allocation Failure) 80832K->19298K(227840K),0.0084018secs]
    [GC (Metadata GC Threshold) 109499K->21465K (228352K),0.0184066 secs]
    [Full GC (Metadata GC Threshold) 21465K->16716K(201728K) ,0.0619261secs ]
  • 参数解析:

    • GC、Full GC:GC的类型,GC只在新生代上进行,Full GC包括永生代,新生代, 老年代。
    • Allocation Failure: GC发生的原因。
    • 80832K -> 19298K:堆在GC前的大小和GC后的大小。
    • 228840k:现在的堆大小。
    • 0.0084018 secs:GC持续的时间。
  • 打开GC日志:

    1
    -verbose: gc -XX: +PrintGCDetails
  • 输入信息如下:

    1
    2
    3
    4
    5
    6
    7
    [GC (Allocation Failure)[PSYoungGen: 70640K->10116K(141312K)] 80541K->20017K (227328K),0.0172573secs]
    [Times: user=0.03 sys=0.00, real=0.02 secs]
    [GC (Metadata GC Threshold) [PSYoungGen: 98859K->8154K(142336K) ] 108760K->21261K (228352K),0.0151573 secs]
    [Times: user=0.00 sys=0.01, real=0.02 secs]
    [Full GC (Metadata GC Threshold) [PSYoungGen: 8154K->0K(142336K) ] [ParOldGen: 13107K->16809K(62464K)] 21261K -> 16809K (204800K),
    [Metaspace: 20599K->20599K (1067008K)],0.0639732 secs]
    [Times: user=0.14 sys=0.00, real=0.06 secs]
  • 参数解析:

    • GC,Full FC:同样是GC的类型
    • Allocation Failure:GC原因
    • PSYoungGen:使用了Parallel Scavenge并 行垃圾收集器的新生代Gc前后大小的变化
    • ParoldGen:使用了Parallel Old并行垃圾收集器的老年代GC前后大小的变化
    • Metaspace:元数据区GC前后大小的变化,JDK1.8中引入了元数据区以替代永久代
    • XXX secs:指GC花费的时间
    • Times: user: 指的是垃圾收集器花费的所有CPU时间,sys: 花费在等待系统调用或系统事件的时间,real:GC从开始到结束的时间,包括其他进程占用时间片的实际时间。
  • 打开GC日志:

    1
    -verbose:gc -XX: +PrintGCDetails -XX: +PrintGCTimeStamps -XX:+PrintGCDateStamps
  • 输入信息如下:

    1
    2
    3
    4
    5
    2019-09-24T22:15:24.518+0800:3.287: [GC(Allocation Failure) [ PSYoungGen: 136162K->5113K (136192K) ] 141425K->17632K (222208K),0.0248249 secs] [Times: user=0. 05 sys=0.00,real=0.03 secs]
    2019-09-24T22:15:25.559+0800:4.329: [GC (Metadata GC Threshold)[ PSYoungGen:97578K->10068K(274944K) ] 110096K->22658K (360960K),0.0094071 secs] [Times: user=0. 00 sys=0.00,real=0.01 secs]
    2019-09-24T22:15:25.569+0800: 4.338: [Full GC (Metadata GC Threshold) [ PSYoungGen:10068K->0K(274944K) ] [ParOldGen: 12590K->13564K (56320K) ] 22658K->13564K (331264K) ,
    [Metaspace: 20590K->20590K(1067008K)], 0.0494875 secs]
    [Times: user=0.17 sys=0.02,real=0.05 secs]
  • 说明:带上了日期和时间

  • 日志补充说明:
    • “ [GC”和” [Full GC” 说明了这次垃圾收集的停顿类型,如果有”Full”则说明GC发生了”Stop The World”
    • 使用Serial收集器在新生代的名字是Default New Generation,因此显示的是” [DefNew”
    • 使用ParNew收集器在新生代的名字会变成” [ParNew”,意思是”Parallel New Generation”
    • 使用Parallel Scavenge收集器在新生代的名字是” [PSYoungGen”
    • 老年代的收集和新生代道理一样,名字也是收集器决定的
    • 使用G1收集器的话,会显示为”garbage- first heap”
    • Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
    • [PSYoungGen: 5986K->696K(8704K) ] 5986K- > 704K (9216K)中
      • 括号内:GC回收前年轻代大小,回收后大小,( 年轻代总大小)
      • 括号外:GC回收前年轻代和老年代大小,回收后大小,( 年轻代和老年代总大小)
    • user代表用户态回收耗时,sys 内核态回收耗时,rea实际耗时。由于多核的原因,时间总和可能会超过real时间

GC日志分析

image-20210430011457790

Minor GC日志:

image-20210430011551243

Full GC日志:

image-20210430012224983

如果想把GC日志存到文件的话,是下面这个参数:

  • Xloggc: ./path/to/gc. log

GC日志分析工具:

  • 可以用一些工具去分析这些gc日志。
  • 常用的日志分析工具有:GCViewerGCEasy、GCHisto、GCLogViewer、Hpjmeter、garbagecat等。

10、垃圾回收器的新发展

1、垃圾回收器的发展

GC仍然处于飞速发展之中,目前的默认选项G1 GC在不断的进行改进,很多我们原来认为的缺点,例如串行的Full GC、 Card Table扫描的低效等,都已经被大幅改进,例如,JDK 10以后,Full GC已经是并行运行,在很多场景下,其表现还略优于Parallel GC的并行Full GC实现。

即使是Serial GC,虽然比较古老,但是简单的设计和实现未必就是过时的,它本身的开销,不管是GC相关数据结构的开销,还是线程的开销,都是非常小的,所以随着云计算的兴起,在Serverless等新的应用场景 下,Serial GC找到了新的舞台

比较不幸的是CMS GC,因为其算法的理论缺陷等原因,虽然现在还有非常大的用户群体,但在JDK9中已经被标记为废弃,并在JDK14版本中移除。

2、JDK11的新特性

image-20210429231614446

3、Open JDK12的Shenandoah GC:低停顿时间的GC (实验性)
  • 现在G1回收器已成为默认回收器好几年了。

  • 我们还看到了引入了两个新的收集器:

    • ZGC( JDK11出现)
    • Shenandoah(Open JDK12)
    • 主打特点:低停顿时间
  • Shenandoah,无疑是众多GC中最孤独的一个。是第一款不由Oracle公司团队领导开发的HotSpot垃圾收集器。不可避免的受到官方的排挤。比如号称OpenJDK和OracleJDK没有区别的Oracle公司仍拒绝在OracleJDK12中支持Shenandoah。

  • Shenandoah垃圾回收器最初由RedHat进行的一项垃圾收集器研究项目Pauseless GC的实现,旨在针对JVM上的内存回收实现低停顿的需求。在2014年贡献给OpenJDK。

  • Red Hat研发Shenandoah团队对外宣称,Shenandoah垃圾回收器的暂停时间与堆大小无关,这意味着无论将堆设置为200MB还是200GB,99.9%的目标都可以把垃圾收集的停顿时间限制在十毫秒以内。不过实际使用性能将取决于实际工作堆的大小和工作负载。

    image-20210430015133115

  • 这是RedHat在2016年发表的论文数据,测试内容是使用Es对200GB的维基百科数据进行索引。从结果看:

    • 停顿时间比其他几款收集器确实有了质的飞跃,但也未实现最大停顿时间控制在十毫秒以内的目标。
    • 而吞吐量方面出现了明显的下降,总运行时间是所有测试收集器里最长的。
  • 总结:

    • Shenandoah GC的弱项:高运行负担下的吞吐量下降王
    • Shenandoah GC的强项:低延迟时间。
    • Shenandoah GC的工作过程大致分为九个阶段,这里就不再赘述。在之前Java12新特性视频里有过介绍。

[Java12新特性地址]
http://www.atguigu.com/download_detail.shtml?v=222

https://www.bilibili.com/video/BV1jJ411M7kQ?from=search&seid=12339069673726242866

4、令人震惊、革命性的ZGC(JDK14新特性)

官网地址

  • ZGC与Shenandoah目标高度相似,在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟

  • 《深入理解Java虚拟机》一书中这样定义ZGC:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-压缩算法的,以低延迟为首要目标的一款垃圾收集器

  • ZGC的工作过程可以分为4个阶段:

    • 并发标记
    • 并发预备重分配
    • 并发重分配
    • 并发重映射等。
  • ZGC几乎在所有地方并发执行的,除了初始标记的是STW(10ms以内)的。所以停顿时间几乎就耗费在初始标记上,这部分的实际时间是非常少的。

  • 测试数据:

    image-20210430015758548

    低延迟:

    image-20210430015925777

  • 在ZGC的强项停顿时间测试上,它毫不留情的将Parallel、G1拉开了两个数量级的差距。无论平均停顿、958停顿、99%停顿、99. 98停顿,还是最大停顿时间,ZGC 都能毫不费劲控制在10毫秒以内。

  • 虽然ZGC还在试验状态,没有完成所有特性,但此时性能已经相当亮眼,用“令人震惊、革命性”来形容,不为过。

  • 未来将在服务端、大内存、低延迟应用的首选垃圾收集器

  • JEP 364: ZGC应用在macOS上

  • JEP 365: ZGC应用在Windows

    • JDK14之前,ZGC仅Linux才支持。
    • 尽管许多使用ZGC的用户都使用类Linux的环境,但在Windows和macOs.上,人们也需要ZGC进行开发部署和测试。许多桌面应用也可以从ZGC中受益。因此,ZGC特性被移植到了Windows和macOS上。
    • 现在mac或windows.上也能使用zGc了,示例如下:
      **-XX: +Unloc kExperimentalVMOptions -XX: +UseZGC**
  • 其它垃圾回收器: AIiGC

    AliGC是阿里巴巴JVM团队基于G1算法,面向大堆 (LargeHeap)应用场景。指定场景下的对比:

    image-20210429233231323

  • 当然,其他厂商也提供了各种独具一格的GC实现, 例如比较有名的低延迟GC:Zing,有兴趣可以参考提供的链接。


18、垃圾回收的相关大厂面试题

  • 蚂蚁金服:
    • 你知道哪几种垃圾回收器,各自的优缺点,重点讲一下cms和G1
    • 一面: JVM GC算法有哪些,目前的JDK版本采用什么回收算法
    • 一面: G1回收器讲下回收过程
    • GC是什么?为什么要有GC?
    • 一面: GC的两种判定方法? CMS收集器与G1收集器的特点。
  • 百度:
    • 说一下GC算法,分代回收说下
    • 垃圾收集策略和算法
  • 天猫:
    • 一面: jvm GC原理,JVM怎么回收内存
    • 一面: CMS特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?
  • 滴滴:
    • 一面: java的垃圾回收器都有哪些,说下G1的应用场景,平时你是如何搭配使用垃圾回收器的
  • 京东:
    • 你知道哪几种垃圾收集器,各自的优缺点,重点讲下cms和G1,包括原理,流程,优缺点。
    • 垃圾回收算法的实现原理。
  • 阿里:
    • 讲一讲垃圾回收算法。
    • 什么情况下触发垃圾回收?
    • 如何选择合适的垃圾收集算法?
    • JVM有哪三种垃圾回收器?
  • 字节跳动:
    • 常见的垃圾回收器算法有哪些,各有什么优劣?
    • system.gc() 和 runtime.gc() 会做什么事情?
    • 一面: Java GC机制? GC Roots有哪些?
    • 二面: Java对象的回收方式,回收算法。
    • CMS和G1了解么,CMS解决什么问题,说一下回收的过程。
    • CMS回收停顿了几次,为什么要停顿两次。

0、其他

1、label

官方:

Refactor the code to remove this label and the need for it.

label标签,不属于关键字,类似于c的goto(很少用),用于标记跳转。底层就是goto语句,尽量不要用

2、怎么学习Java的不同版本的新特性

Java不同版本的新特性:

  1. 语法层面:Lambda表达式、switch表达式、 自动装箱、自动拆箱、enum关键字、 <>泛式等等
  2. API层面:Stream API、新的日期时间、Optional、 String、 集合框架
  3. 底层优化:JVM的优化,GC的变化、元空间、静态域、字符串常量池等

中篇:字节码与类的加载器

1、class文件结构

1、概述

1、字节码文件的跨平台性
  1. Java语言: 跨平台的语言(write once ,run anywhere)

    • 当Java源代码成功编译成字节码后,如果想在不同的平台上面运行,则无须再次编译
    • 这个优势不再那么吸引人了。Python、 PHP、 Perl、 Ruby、 Lisp等有强大的解释器。
    • 跨平台似乎已经快成为一门语言必选的特性。
  2. Java虚拟机:跨语言的平台

    • Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联

    • 无论使用何种语言进行软件开发,只要能将源文件编译为正确的Class文件,那么这种语言就可以在Java虚拟机上执行。

    • 可以说,统一而强大的Class文件结构,就是Java虚拟机的基石、桥梁。

      第01章_JVM跨语言的平台

    • 所有的JVM全部遵守Java虚拟机规范,也就是说所有的JVM环境都是一样的,这样一来字节码文件可以在各种JVM上运行。

    • 官方文档

  3. 想要让一个Java程序正确地运行在JVM中, Java源码就必须要被编译为符合JVM规范的字节码。

    • 前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件

    • javac一种能够将Java源码编译为字节码的前端编译器

    • javac编译器在将Java源码编译为个有效的字节码文件过程中经历了4个步骤,分别是词法解析、语法解析、语义解析以及生成字节码。

      image-20210504004503138

  4. Oracle的JDK软件包括两部分内容:

    • 一部分是将Java源代码编译成Java虚拟机的指令集的编译器
    • 另一部分是用于实现Java虛拟机的运行时环境
2、Java的前端编译器
1、关于前端编译器与后台编译器在程序编译过程中的作用

image-20210504005333757

2、前端编译器vs后端编译器

Java源代码的编译结果是字节码,那么肯定需要有一种编译器能够将Java源码编译为字节码,承担这个重要责任的就是配置在path环境变量中的javac编译器。javac是一种能够将Java源码编译为字节码的前端编译器

HotSpotVM并没有强制要求前端编译器只能使用javac来编译字节码,其实只要编译结果符合JVM规范都可以被JVM所识别即可。在Java的前端编译器领域,除了javac之外, 还有一种被大家经常用到的前端编译器,那就是内置在Eclipse中的ECJ (Eclipse Compiler for Java )编译器。和Javac的全量式编译不同,ECI是一种增量式编译器

  • 在Eclipse中,当开发人员编写完代码后,使用“Ctrl+S”快捷键时,ECI编译器所采取的编译方案是把未编译部分的源码逐行进行编译,而非每次都全量编译。因此ECI的编译效率会比javac更加迅速和高效,当然编译质量和javac相比大致还是一样的。
  • ECI不仅是Eclipse的默认内置前端编译器,在Tomcat中同样也是使用ECJ编译器来编译jsp文件。由于ECJ编译器是采用GPLv2的开源协议进行源代码公开,所以,大家可以登录eclipse官网下载ECJ编译器的源码进行二次开发。
  • 默认情况下,IntelliJ IDEA使用javac编译器。(还可以自己设置为AspectJ编译器 ajc)

前端编译器并不会直接涉及编译优化等方面的技术,而是将这些具体优化细节移交给HotSpot的JIT编译器(后端编译器)负责

复习:

  • JIT(及时编译器)
  • AOT(静态提前编译器,Ahead of Time Compiler)
3、透过字节码指令看代码细节

BAT面试题:

  1. 类文件结构有几个部分?
  2. 知道字节码吗?字节码都有哪些? Integer x = 5;int y = 5;比较x == y都经过哪些步骤?

代码举例1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class IntegerTest {
public static void main(String[] args) {

Integer x = 5;
int y = 5;
System.out.println(x == y);//true。自动拆箱

Integer i1 = 10;
Integer i2 = 10;
System.out.println(i1 == i2);//true

Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);//false
}
}

相关字节码1:

image-20210518205703047

image-20210518205820353

image-20210518205932030

image-20210518205941131

代码举例2:

1
2
3
4
5
6
7
8
9
public class StringTest {
public static void main(String[] args) {
String str = new String("hello") + new String("world");
String str1 = "helloworld";
System.out.println(str == str1);//false
String str2 = new String("helloworld");
System.out.println(str == str2);//false
}
}

相关字节码2:

image-20210518215156842

image-20210518215729244\

image-20210518215755492

代码举例3:

image-20210518220102931

相关说明:

  • 成员变量(非静态的)的赋值过程:
    • 默认初始化(注意这里会先调用所有父类的构造方法(super))
    • 显式初始化 / 代码块中初始化
    • 构造器中初始化
    • 有了对象之后,可以”对象.属性”或”对象.方法”的方式对成员变量进行赋值。

相关字节码3:

对son:

image-20210518221159306

代码的执行过程:

  1. 执行Father f = new Son();
  2. 先初始化父类的构造器,在父类的构造器当中调用了print()方法
  3. 又因为Son重写了父类Father的print()方法,此时又没有到执行显示初始化的步骤(看上面的字节码文件)
  4. 因此打印的是Son.x = 0
  5. 接着初始化Son本身的构造器,Son本身的构造器当中调用了print()方法
  6. 此时已经经历了显示初始化,x被赋予值30(具体看上面的成员变量(非静态的)的赋值过程)
  7. 因此打印的是Son.x = 30
  8. 最后执行System.out.println(f.x);
  9. **因为属性不存在多态性!!!**变量f的声明类型是Father,所以它是Father类型的,不是Son类型。
  10. 所以f.x中的x也是Father的x,因此打印的才是20

2、虚拟机的基石:class文件

  • 字节码文件里是什么?

    源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是JVM的指令,而不像C、C++经由编译器直接生成机器码

  • 什么是字节码指令(byte code)?
    Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。

    比如:操作码 (操作数)

    image-20210504010005360

  • 如何解读供虚拟机解释执行的二进制字节码?

    1. 方式一:一个一个二进制的看。这里用到的是Notepad++,需要安装一个HEX-Editor插件,或者使用Binary Viewer
    2. 方式二:使用javap指令:jdk 自带的反解析工具。eg:javap -v IntegerTest.class >IntegerTest.txt
    3. 方式三:使用IDEA插件:jclasslib或jclasslib bytecode viewer客户端工具。(可视化更好)

3、class文件结构

1、关于Class文件
  • 相关的官方文档

    javase8 JVM-ClassFile的相关资料

  • Class类的本质

    • 任何一个Class文件都对应着唯一个类或接口的定义信息,但反过来说,Class文件实际上它并不一定以磁盘文件的形式存在。
    • Class 文件是一组以8位字节为基础单位的二进制流。(该二进制流可以来自于磁盘,也可以来自于网络)
  • Class文件格式

    • Class的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限
      定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
    • 例子:
      • “下雨天留客天留我不留”:
      • “下雨天,留客天,留我不留?”
      • “下雨天,留客天,留我不?留!”
      • “下雨,天留客?天留,我不留!”
    • 目的:压缩字节码文件
  • Class文件格式采用一种类似于C语言结构体的方式进行数据存储,这种结构中只有两种数据类型:**无符号数**。

    • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
    • 是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。由于表没有固定长度,所以通常会在其前面加上个数说明
  • 相关代码举例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * 全类名:com.atguigu.java1.Demo
    * 全限定名:com/atguigu/java1/Demo
    */
    public class Demo {
    private int num = 1;
    public int add(){
    num = num + 2;
    return num;
    }
    }

    经过javac编译过后的16进制的Class文件:

    image-20210519000727861

    经过插件——jclasslib反编译后的Class文件:

    image-20210519001523659

    换句话说,充分理解了每一个字节码文件的细节,自己也可以反编译出Java源文件来

  • Class文件结构概述:Class文件的结构并不是一成不变的,随着Java虛拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但是其基本结构和框架是非常稳定的。

    Class文件的总体结构如下:

    • 魔数
      • u4 magic;
    • Class文件版本
      • u2 minor_version;
      • u2 major_version;
    • 常量池
      • u2 constant_pool_count;
      • cp_info constant_pool[constant_pool_count-1];
    • 访问标志
      • u2 access_flags;
    • 类索引,父类索引,接口索引集合
      • u2 this_class;
      • u2 super_class;
      • u2 interfaces_count;
      • u2 interfaces[interfaces_count];
    • 字段表集合
      • u2 fields_count;
      • field_info fields[fields_count];
    • 方法表集合
      • u2 methods_count;
      • method_info methods[methods_count];
    • 属性表集合
      • u2 attributes_count;
      • attribute_info attributes[attributes_count];

    The ClassFile Structure:(来自官网)

    image-20210519001835589

    这是一张Java字节码总的结构表,我们按照上面的顺序逐一进行解读就可以了:

    类型 名称 说明 长度 数量
    u4 magic 魔数,识别Class文件格式 4个字节 1
    u2 minor_version 副版本号(小版本) 2个字节 1
    u2 major_version 主版本号(大版本) 2个字节 1
    u2 constant_pool_count 常量池计数器 2个字节 1
    cp_info constant_pool 常量池表 n个字节 constant_pool_count-1
    u2 access_flags 访问标识 2个字节 1
    u2 this_class 类索引 2个字节 1
    u2 super_class 父类索引 2个字节 1
    u2 interfaces_count 接口计数器 2个字节 1
    u2 interfaces 接口索引集合 2个字节 interfaces_count
    u2 fields_count 字段计数器 2个字节 1
    field_info fields 字段表 n个字节 fields_count
    u2 methods_count 方法计数器 2个字节 1
    method_info methods 方法表 n个字节 methods_count
    u2 attributes_count 属性计数器 2个字节 1
    attribute_info attributes 属性表 n个字节 attributes_count
2、01-魔数:Class文件的标志

**Magic Number (魔数)**:

  • 每个Class文件开头的4个字节的无符号整数称为魔数(Magic Number)

  • 它的唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的Class文件。即:魔数是Class文件的标识符。

  • 魔数值固定为**0xCAFEBABE**。不会改变。

  • 如果一个Class文件不以0xCAFEBABE开头,虚拟机在进行文件校验的时候就会直接抛出以下错误:

    1
    2
    Error: A JNI error has occurred, please check your installation and try again
    Exception in thread "main" java.lang.ClassFormatError:Incompatible magic value 1885430635 in class file StringTest
  • 使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。

  • 其实魔数不只是在class文件当中有所应用。在图片.png、音乐.mp3等等,里面也有前几位作为魔数,作为进行对应文件的标识符

3、02-Class文件版本号
  • 紧接着魔数的4个字节存储的是Class 文件的版本号。同样也是4个字节。第5个和第6个字节所代表的含义就是编译的副版本号minor_ version, 而第7个和第8个字节就是编译的主版本号major_ version

  • 它们共同构成了class文件的格式版本号。譬如某个 Class文件 的主版本号为 M,副版本号为 m,那么这个 Class文件 的格式版本号就确定为 M.m。

  • 版本号和Java编译器的对应关系如下表:

    主版本(十进制) 副版本(十进制) 编译器版本
    45 3 1.1
    46 0 1.2
    47 0 1.3
    48 0 1.4
    49 0 1.5
    50 0 1.6
    51 0 1.7
    52(十六进制对应的是34) 0 1.8
    53 0 1.9
    54 0 1.10
    55 0 1.11
  • Java的版本号是从**45**开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1。

  • 虚拟机JDK版本为1.k (k >= 2) 时,对应的class文件格式版本号的范围为45.0 - 44+k.0 (含两端)

  • 不同版本的Java编译器编译的Class文件对应的版本是不一样的。目前,高版本的Java虚拟机可以执行由低版本编译器生成的Class文件,但是低版本的Java虛拟机不能执行由高版本编译器生成的Class文件。否则JVM会抛出java.lang.UnsupportedClassVersionError异常。( 向下兼容)

  • 在实际应用中,由于开发环境和生产环境的不同,可能会导致该问题的发生。因此,需要我们在开发时,特别注意开发编译的JDK版本和生产环境中的JDK版本是否一致

  • 总结成一句话就是:高版本的虚拟机可以解释运行低版本的字节码文件

4、03-常量池:存放所有常量
1、常量池概述
  • 常量池是Class文件中内容最为丰富的区域之一。常量池对于Class文件中的字段和方法解析也有着至关重要的作用。

  • 随着Java虚拟机的不断发展,常量池的内容也日渐丰富。可以说,常量池是整个Class文件的基石

  • 官方文档:

    image-20210519012259460

  • 在版本号之后,紧跟着的是常量池的数量,以及若干个常量池表项。

  • 常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的无符号数,代表常量池容量计数值(constant_ pool_ count) 。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的。

    类型 名称 数量
    u2 constant_pool_count 1
    cp_info constant_pool constant_pool_count-1
  • 由上表可见,Class文件使用了一个前置的容量计数器(constant_pool_count) 加若干个连续的数据项(constant_pool) 的形式来描述常量池内容。我们把这一 系列连续常量池数据称为常量池集合。

  • 常量池表项中,用于存放编译时期生成的各种**字面量符号引用,这部分内容将在类加载后进入方法区的运行时常量池**中存放。(其中字符串常量池在jdk7以后被移进堆空间中)

2、常量池计数器(constant_pool_ count)
  • 由于常量池的数量不固定,时长时短,所以需要放置两个字节来表示常量池容量计数值

  • 常量池容量计数值(u2类型) :从1开始,表示常量池中有多少项常量。即 constant_pool_count=1 表示常量池中有0个常量项

  • Demo的值为:

    image-20210519013853054

    其值为0x0016,转换为十进制,也就是22。

    需要注意的是,这实际上只有21项常量。索引为范围是1-21。为什么呢?

    • 通常我们写代码时都是从0开始的,但是这里的常量池却是从1开始,因为它把第0项常量空出来了。
    • 这是为了满足后面某些指向常星池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可用索引值0来表示。
3、常量池表
  • constant_ pool是一种表结构, 以1 ~ constant_ pool_ count - 1为索引。表明了后面有多少个常量项。

  • 常量池主要存放两大类常量:**字面量(Literal)****符号引用(Symbolic References )**

  • 它包含了class文件结构及其子结构中引用的所有字符串常量类或接口名字段名其他常量。常量池中的每一项都具备相同的特征。第1个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte(标记字节、标签字节)。

  • tag byte与对应的类型:(最后三个是在jdk7添加的,体现了java对动态语言的支持)

    类型 标志(或标识) 描述
    CONSTANT_utf8_info 1 UTF-8编码的字符串
    CONSTANT_Integer_info 3 整型字面量
    CONSTANT_Float_info 4 浮点型字面量
    CONSTANT_Long_info 5 长整型字面量
    CONSTANT_Double_info 6 双精度浮点型字面量
    CONSTANT_Class_info 7 类或接口的符号引用
    CONSTANT_String_info 8 字符串类型字面量
    CONSTANT_Fieldref_info 9 字段的符号引用
    CONSTANT_Methodref_info 10 类中方法的符号引用
    CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
    CONSTANT_NameAndType_info 12 字段或方法的符号引用
    CONSTANT_MethodHandle_info 15 表示方法句柄
    CONSTANT_MethodType_info 16 标志方法类型
    CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点
4、常量池表——字面量和符号引用
  • 字面量和符号引用:

    • 常量池主要存放两大类常量:

      • 字面量(Literal)
      • 符号引用(Symbolic References) 。
    • 如下表:

      常量 具体的常量 举例
      字面量 文本字符串 String str = “Hello”;
      声明为final的常星值 final int NUM = 10;
      符号引用 类和接口的全限定名 com/atguigu/test/Demo;
      字段的名称和描述符 add、num
      方法的名称和描述符
  • 全限定名

    • com/atguigu/test/Demo这个就是类的全限定名,仅仅是**把包名的”.”替换成”/“**,
    • 为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个”;”表示全限定名结束
  • 简单名称

    • 简单名称是指没有类型和参数修饰方法或者字段名称,上面例子中的类的add()方法和num字段的简单名称分别是add和num。
  • 描述符

    • 描述符的作用是用来描述字段的数据类型方法的参数列表(包括数量、类型以及顺序)**和返回值**。

    • 根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean) 以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见下表:(基本数据类型long对应的是J;基本数据类型boolean对应的是Z;几个[表示几维数组)

      标志符 含义
      B 基本数据类型byte
      C 基本数据类型char
      D 基本数据类型double
      F 基本数据类型float
      I 基本数据类型int
      J 基本数据类型long
      S 基本数据类型short
      Z 基本数据类型boolean
      V 代表void类型
      L 对象类型,比如:Ljava/lang/Object;
      [ 数组类型,代表一维数组。比如:double[][][] is [[[D
    • 用描述符来描述方法时,按照先参数列表后返回值的顺序描述参数列表按照参数的严格顺序放在一组小括号”()”之内

      • 如方法java.lang.String toString()的描述符为:() Ljava/lang/String;
      • 方法int abc(int[] x, int y)的描述符为([II) I
  • 补充说明:

    • 虚拟机在加载Class文件时才会进行动态链接,也就是说,Class 文件中不会保存各个方法和字段的最终内存布局信息,因此,这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。当虚拟机运行时,需要从常量池中获得对应的符号引用,再在类加载过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中
    • 这里说明下符号引用直接引用的区别与关联:
      • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
      • 直接引用:直接引用可以是直接指向目标的指针相对偏移量或是个能间接定位到目标的句柄直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。 如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
      • 加载前类的方法等信息只是字符串(字面量与符号引用),而加载后会把这个字符串(字面量与符号引用)替换成相对应的内存地址。
5、常量池表——常量类型和结构

常量池中每一项常量都是一个表,JDK1.7之后共有14种不同的表结构数据。如下表格所示:

常量类型和结构细节:

1598773300484

1598773308492

  • 根据上图每个类型的描述我们也可以知道每个类型是用来描述常量池中哪些内容(主要是字面量、符号引用)的。比如:CONSTANT_Integer_info是用来描述常量池中字面量信息的,而且只是整型字面量信息。
  • 标志为15、16、18的常量项类型是用来支持动态语言调用的(jdk1.7时才加入的)。
  • 细节说明:
    • CONSTANT_Class_info:结构用于表示类或接口
    • CONSTANT_Fieldref_info、CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info结构:表示字段、方法和接口方法
    • CONSTANT_string_info结构用于表示String类型的常量对象
    • CONSTANT_Integer_info和CONSTANT_Float_info:表示4字节(int 和float)的数值常量
    • CONSTANT_Long_info和ICONSTANT Double_info结构:表示8字节(long和double)的数值常量
      • 在class文件的常量池表中,所有的8字节常量均占两个表成员(项)的空间。如果一个CONSTANT_Long_info或CONSTANT_Double_info结构的项在常量池表中的索引位n,则常量池表中下一个可用项的索引位n+2,此时常量池表中索引为n+1的项仍然有效但必须视为不可用的。
    • CONSTANT_NameAndType_info结构用于表示字段或方法,但是和之前的3个结构不同,CONSTANT_NameAndType_info结构没有指明该字段或方法所属的类或接口。
    • CONSTANT_utf8_info用于表示字符常量的值
    • CONSTANT_MethodHandle_info结构用于表示方法句柄
    • CONSTANT_MethodType_info结构:表示方法类型
    • CONSTANT_InvokeDynamic_info结构用于表示invokedynamic指令所用到的引导方法(bootstrap method)、 引导方法所用到的动态调用名(dynamic invocation name)、 参数和返回类型,并可以给引导方法传入一系列称为静态参数(static argument)的常量。
  • 解析方式:
    • 一个字节一个字节的解析
    • 使用javap命令解析:javap -verbose Demo.class 或 jclasslib工具会更方便。
  • 总结1:
    • 这14种表(或者常量项结构)的共同点是:表开始的第一位是一个u1类型的标志位(tag),代表当前这个常量项使用的是哪种表结构,即哪种常量类型。
    • 在常量池列表中,CONSTANT_Utf8_info常量项是一种使用改进过的UTF - 8编码格式来存储诸如文字字符串、类或者接口的全限定名、字段或者方法的简单名称以及描述符等常量字符串信息。
    • 这14种常量项结构还有一个特点是,其中13个常量项占用的字节固定,只有CONSTANT_Utf8_info占用字节不固定,其大小由length决定。为什么呢?
      • 因为从常量池存放的内容可知,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序时才确定,比如你定义一个类, 类名可以取长取短,所以在没编译前,大小不固定,编译后,通过utf-8编码, 就可以知道其长度。
  • 总结2:
    • 常量池:可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件空间最大的数据项目之一。
    • 常量池中为什么要包含这些内容
      • Java代码在进行Javac编译的时候, 并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态链接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态链接的内容,在虚拟机类加载过程时再进行详细讲解。
5、04-访问标识

访问标识(access_ flag、访问标志、访问标记)

  • 在常量池后,紧跟着访问标记。该标记使用两个字节表示,用于识别一些类或者接口层次的访问信息,包括:

    • 这个Class是类还是接口;
    • 是否定义为public类型;
    • 是否定义为abstract类型;如果是类的话,是否被声明为final等。
  • 各种访问标记如下所示:

    标志名称 标志值 含义
    ACC_PUBLIC 0x0001 标志为public类型
    ACC_FINAL 0x0010 标志被声明为final,只有类可以设置
    ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
    ACC_INTERFACE 0x0200 标志这是一个接口
    ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
    ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
    ACC_ANNOTATION 0x2000 标志这是一个注解
    ACC_ENUM 0x4000 标志这是一个枚举
  • 类的访问权限通常为ACC_ 开头的常量。

  • 每一种类型的表示都是通过设置访问标记的32位中的特定位来实现的。比如,若是public final的类,则该标记为ACC_PUBLIC | ACC_FINAL。

  • 使用ACC_SUPER可以让类更准确地定位到父类的方法super.method(),现代编译器都会设置并且使用这个标记。

  • 补充说明:

    1. 带有ACC_INTERFACE标志的class文件表示的是接口而不是类,反之则表示的是类而不是接口。
      1. 如果一个class文件被设置了ACC_INTERFACE 标志,那么同时也得设置ACC_ABSTRACT 标志。同时它不能再设置ACC_FINAL、ACC_SUPER或ACC_ENUM标志。
      2. 如果没有设置ACC_INTERFACE标志,那么这个class文件可以具有上表中除ACC_ANNOTATION外的其他所有标志。当然,ACC_FINAL和ACC_ ABSTRACT这类互斥的标志除外。这两个标志不得同时设置。
    2. ACC_SUPER标志用于确定类或接口里面的invokespecial指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器都应当设置这个标志。对于Java SE 8及后续版本来说,无论class文件中这个标志的实际值是什么,也不管class文件的版本号是多少,Java虛拟机都认为每个class文件均设置了ACC_SUPER标志。
      1. ACC_SUPER标志是为了向后兼容由旧Java编译器所编译的代码而设计的。目前的ACC_SUPER标志在由JDK 1.0.2之前的编译器所生成的access_flags中是没有确定含义的,如果设置了该标志,那么Oracle的Java虛拟机实现会将其忽略。
    3. ACC_SYNTHETIC标志意味着该类或接口是由编译器生成的,而不是由源代码生成的。
    4. 注解类型必须设置ACC_ANNOTATION标志。如果设置了ACC_ANNOTATION标志,那么也必须设置ACC_ INTERFACE标志。
    5. ACC_ENUM标志表明该类或其父类为枚举类型。
6、05-类索引、父类索引、接口索引集合
  • 在访问标记后,会指定该类的类别、父类类别以及实现的接口,格式如下:

    长度 含义
    u2 this_class
    u2 super_class
    u2 interfaces_count
    u2 interfaces[interfaces_count]
  • 这三项数据来确定这个类的继承关系:

    • 类索引用于确定这个类的全限定名
    • 父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。
    • 接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends 语句)后的接口顺序从左到右排列在接口索引集合中。
  1. this_class (类索引)
    • 2字节无符号整数,指向常量池的索引。它提供了类的全限定名, 如com/atguigu/java1/Demo。this_ class的值必须是对常量池表中某项的一个有效索引值。常量池在这个索引处的成员必须为CONSTANT_Class_info类型结构体,该结构体表示这个class文件所定义的类或接口。
  2. super_class (父类索引)
    • 2字节无符号整数,指向常量池的索引。它提供了当前类的父类的全限定名。如果我们没有继承任何类,其默认继承的是java/lang/Object类。同时,由于Java不支持多继承,所以其父类只有一个。
    • superclass指向的父类不能是final。
  3. interfaces
    • 指向常量池索引集合,它提供了一个符号引用到所有己实现的接口
    • 由于一个类可以实现多个接口,因此需要以数组形式保存多个接口的索引,表示接口的每个索引也是一个指向常量池的CONSTANT_Class ( 当然这里就必须是接口,而不是类)。
  • interfaces_count (接口计数器):interfaces_ count 项的值表示当前类或接口的直接超接口数量。
  • interfaces [](接口索引集合):
    interfaces []中每个成员的值必须是对常量池表中某项的有效索引值,它的长度为interfaces_count。每个成员interfaces[i]必须为CONSTANT_Class_ info结构, 其中0 <= i < interfaces_count。 在interfaces []中,各成员所表示的接口顺序和对应的源代码中给定的接口顺序(从左至右)一样,即interfaces [0]对应的是源代码中最左边的接口。
7、06-字段表集合
1、字段表集合

fields:

  • 用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但是不包括方法内部、代码块内部声明的局部变量。(local variables)
  • 字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
  • 它指向常量池索引集合,它描述了每个字段的完整信息。比如**字段的标识符、访问修饰符(public、private或protected)、是类变量还是实例变量(static修饰符)、是否是常量(final修饰符)**等。

注意事项:

  • 字段表集合中不会列出从父类或者实现的接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
  • Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称, 但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。
2、fields_ count(字段计数器)
  • fields_count的值表示当前class文件fields表的成员个数。 使用两个字节来表示
  • fields表中每个成员都是一个field_info结构, 用于表示该类或接口所声明的所有类字段或者实例字段,不包括方法内部声明的变量,也不包括从父类或父接口继承的那些字段
3、fields [](字段表)
  • fields表中的每个成员都必须是个fields_info结构的数据项,用于表示当前类或接口中某个字段的完整描述。

  • 一个字段的信息包括如下这些信息。这些信息中,各个修饰符都是布尔值,要么有,要么没有

    • 作用域(public、private、protected修饰符)
    • 是实例变量还是类变量(static修饰符)
    • 可变性(final)
    • 并发可见性(volatile修饰符,是否强制从主内存读写)
    • 可否序列化(transient修饰符)
    • 字段数据类型(基本数据类型、对象、数组)
    • 字段名称
  • 字段表作为一个表,同样有他自己的结构:

    类型 名称 含义 数量
    u2 access_flags 访问标志 1
    u2 name_index 字段名索引 1
    u2 descriptor_index 描述符索引 1
    u2 attributes_count 属性计数器 1
    attribute_info attributes 属性集合 attributes_count
  • 字段表访问标识

    • 我们知道,一 个字段可以被各种关键字去修饰,比如:作用域修饰符(public、private、protected) 、static修饰符 、final修饰符、volatile修饰符等等。因此,其可像类的访问标志那样,使用一些标志来标记字段。字段的访问标志有如下这些:

      标志名称 标志值 含义
      ACC_PUBLIC 0x0001 标志为public类型
      ACC_FINAL 0x0010 标志被声明为final,只有类可以设置
      ACC_SUPER 0x0020 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真。(使用增强的方法调用父类方法)
      ACC_INTERFACE 0x0200 标志这是一个接口
      ACC_ABSTRACT 0x0400 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
      ACC_SYNTHETIC 0x1000 标志此类并非由用户代码产生(即:由编译器产生的类,没有源码对应)
      ACC_ANNOTATION 0x2000 标志这是一个注解
      ACC_ENUM 0x4000 标志这是一个枚举
  • 字段名索引:根据字段名索引的值,查询常量池中的指定索引项即可。

  • 描述符索引

    • 描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte, char, double, float,int,long, short , boolean)及代表无返回值的void类型都用一个大写字符来表示, 而对象则用字符L加对象的全限定名来表示,如下所示:

      标志符 含义
      B 基本数据类型byte
      C 基本数据类型char
      D 基本数据类型double
      F 基本数据类型float
      I 基本数据类型int
      J 基本数据类型long
      S 基本数据类型short
      Z 基本数据类型boolean
      V 代表void类型
      L 对象类型,比如:Ljava/lang/Object;
      [ 数组类型,代表一维数组。比如:double[][][] is [[[D
  • 属性表集合

    • 一个字段还可能拥有一些属性,用于存储更多的额外信息。比如初始化值、一些注释信息等。属性个数存放在attribute_count中,属性具体内容存放在attributes数组中。

    • 例子:以常量属性为例,结构为:

      1
      2
      3
      4
      5
      ConstantValue_attribute{
      u2 attribute_name_index;
      u4 attribute_length;
      u2 constantvalue_index;
      }

      说明:对于常量属性而言,attribute_length值恒为2。

      根据上面的例子,我们来实际分析一下,如下图:

      img

8、07-方法表集合
1、方法表集合

methods:指向常量池索引集合,它完整描述了每个方法的签名。

  • 在字节码文件中,每一个method_info项都对应着个类或者接口中的方法信息。比如方法的访问修饰符(public、private或protected),方法的返回值类型以及方法的参数信息等。
  • 如果这个方法不是抽象的或者不是native的,那么字节码中会体现出来。
  • 一方面,methods表只描述当前类或接口中声明的方法,不包括从父类或父接口继承的方法。另一方面,methods表有可能会出现由编译器自动添加的方法,最典型的便是编译器产生的方法信息(比如:类(接口)初始化方法()**和实例初始化方法()**)。

使用注意事项:

  • 在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名之中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
  • 但在Class文件格式中, 特征签名的范围更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个class文件中。
  • 也就是说,尽管Java语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但是和Java语法规范相反,字节码文件中却恰恰允许存放多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值不能相同
2、methods_ count(方法计数器)
  • methods_count的值表示当前class文件methods表的成员个数。使用两个字节来表示
  • methods表中每个成员都是一个method_info结构
3、methods [] (方法表)
  • methods表中的每个成员都必须是一个method_info结构, 用于表示当前类或接口中某个方法的完整描述。如果某个method_info结构的access_flags项既没有设置ACC_NATIVE标志,也没有设置ACC_ABSTRACT标志,那么该结构中也应包含实现这个方法所用的Java虚拟机指令。

  • method_info结构可以表示类和接口中定义的所有方法,包括实例方法、类方法、实例初始化方法和类或接口初始化方法

  • 方法表的结构实际跟字段表是一样的,方法表结构如下:

    类型 名称 含义 数量
    u2 access_flags 访问标志 1
    u2 name_index 字段名索引 1
    u2 descriptor_index 描述符索引 1
    u2 attributes_count 属性计数器 1
    attribute_info attributes 属性集合 attributes_count
  • 方法表访问标志

    • 跟字段表一样,方法表也有访问标志,而且他们的标志有部分相同,部分则不同,方法表的具体访问标志如下:

      标记名 说明
      ACC_PUBLIC 0x0001 public,方法可以从包外访问
      ACC_PRIVATE 0x0002 private,方法只能本类中访问
      ACC_PROTECTED 0x0004 protected,方法在自身和子类可以访问
      ACC_STATIC 0x0008 static,静态方法
9、08-属性表集合
1、属性表集合(attributes)
  • 方法表集合之后的属性表集合,指的是class 文件所携带的辅助信息,比如该class 文件的源文件的名称。以及任何带有RetentionPolicy.CLASS或者RetentionPolicy.RUNTIME的注解。这类信息通常被用于Java虚拟机的验证和运行,以及Java程序的调试,一般无须深入了解
  • 此外,字段表、方法表都可以有自己的属性表。用于描述某些场景专有的信息。
  • 属性表集合的限制没有那么严格,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,但Java虛拟机运行时会忽略掉它不认识的属性
2、attributes_ count(属性计数器)
  • attributes_count的值表示当前class文件属性表的成员个数。属性表中每一项都是个attribute_info结构。
3、attributes [] ( 属性表)
  • 属性表的每个项的值必须是attribute_ info结构。属性表的结构比较灵活,各种不同的属性只要满足以下结构即可。

  • 属性的通用格式:(即只需说明属性的名称以及占用位数的长度即可,属性表具体的结构可以去自定义)

    类型 名称 数量 含义
    u2 attribute_name_index 1 属性名索引
    u4 attribute_length 1 属性长度
    u1 info attribute_length 属性表
  • 属性类型:

    属性表实际上可以有很多类型,上面看到的Code属性只是其中一种,Java8里面定义了23种属性。下面这些是虚拟机中预定义的属性:

    属性名称 使用位置 含义
    Code 方法表 Java代码编译成的字节码指令
    ConstantValue 字段表 final关键字定义的常量池
    Deprecated 类,方法,字段表 被声明为deprecated的方法和字段
    Exceptions 方法表 方法抛出的异常
    EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
    InnerClass 类文件 内部类列表
    LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
    LocalVariableTable Code属性 方法的局部变量描述
    StackMapTable Code属性 JDK1.6中新增的属性.供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
    Signature 类,方法,字段表 用于支持泛型情况下的方法签名
    Sourcefile 类文件 记录源文件名称
    SourceDebugExtension 类文件 用于存储额外的调试信息
    Synthetic 类,方法,字段表 标志方法或字段为编译器自动生成的
    LocalVariableTypeTable 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
    RuntimeVisibleAnnotations 类,方法,字段表 为动态注解提供支持
    RuntimeInvisibleAnnotations 类,方法,字段表 用于指明哪些注解是运行时不可见的
    RuntimeVisibleParameterAnnotation 方法表 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法
    RuntimeInvisibleParameterAnnotation 方法表 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数
    AnnotationDefault 方法表 用于记录注解类元素的默认值
    BootstrapMethods 类文件 用于保存invokeddynamic指令引用的引导方式限定符

    或者(查看官网):

    img

  • 部分属性详解

    1. ConstantValue 属性:

      ConstantValue 属性表示一个常量字段的值。位于field_info结构的属性表中。

      1
      2
      3
      4
      5
      6
      ConstantValue_attribute {
      u2 attribute_name_index;
      u4 attribute_length;
      u2 constantvalue_index;//字段值在常量池中的索引,常量池在该索引处的项给出该属性表示的常量值。
      //(例如,值是1ong型的, 在常量池中便是CONSTANT_ Long )
      }
    2. Deprecated 属性:

      Deprecated属性是在JDK 1.1为了支持注释中的关键词@deprecated而引入的。

      1
      2
      3
      4
      Deprecated_attribute {
      u2 attribute name_index;
      u4 attribute_length;
      }
    3. Code 属性:

      Code属性就是存放方法体里面的代码。但是,并非所有方法表都有Code属性。像接口或者抽象方法,他们没有具体的方法体,因此也就不会有Code属性了。

      Code属性表的结构,如下图:

      类型 名称 数量 含义
      u2 attribute_name_index 1 属性名索引
      u4 attribute_length 1 属性长度
      u2 max_stack 1 操作数栈深度的最大值
      u2 max_locals 1 局部变量表所需的存续空间
      u4 code_length 1 字节码指令的长度
      u1 code code_length 存储字节码指令
      u2 exception_table_length 1 异常表长度
      exception_info exception_table exception_length 异常表
      u2 attribute_count 1 属性集合计数器
      attribute_info attributes attribute_count 属性集合

      可以看到:Code属性表的前两项跟属性表是-致的,即Code属性表遵循属性表的结构,后面那些则是他自定义的结构。

    4. InnerClasses 属性:

      • 为了方便说明特别定义一个表示类或接口的Class格式为C。如果C的常量池中包含某个CONSTANT_Class_info成员,且这个成员所表示的类或接口不属于任何一个包,那么C的ClassFile结构的属性表中就必须含有对应的InnerClasses属性。
      • InnerClasses属性是在JDK 1.1中为了支持内部类和内部接口而引入的,位于ClassFile结构的属性表。
    5. LineNumberTable 属性:

      • LineNumberTable属性是可选变长属性,位于Code结构的属性表。

      • LineNumberTable属性是用来描述Java源码行号与字节码行号之间的对应关系。这个属性可以用来在调试的时候定位代码执行的行数。

        • start_pc,即字节码行号;
        • line_number,即Java源代码行号。
      • 在Code属性的属性表中,LineNumberTable属性可以按照任意顺序出现,此外,多个LineNumberTable属性可以共同表示一个行号在源文件中表示的内容, 即LineNumberTable 属性不需要与源文件的行一对应。

      • LineNumberTable属性表结构:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        LineNumberTable_attribute{
        u2 attribute_name_index:
        u4 attribute_length;
        u2 line_number_table_length;
        {
        u2 start_pc;
        u2 line_number;
        } line_number_table[line_number_table_length];
        }
    6. LocalVariableTable 属性:

      • LocalVariableTable 是可选变长属性,位于Code属性的属性表中。它被调试器用于确定方法在执行过程中局部变量的信息

      • 在Code属性的属性表中,LocalVariableTable 属性可以按照任意顺序出现。

      • Code属性中的每个局部变量最多只能有一个LocalVariableTable 属性。

        • startpc + length表示这个变量在字节码中的生命周期起始和结束的偏移位置(this生命周期从头0到结尾10)
        • index就是这个变量在局部变量表中的槽位(槽位可复用)
        • name就是变量名称
        • Descriptor表示局部变量类型描述
      • LocalVariableTable属性表结构:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        LocalVariableTable_attribute{
        u2 attribute_name_index;
        u4 attribute_length;
        u2 local_variable_table_length;
        {
        u2 start_pc;
        u2 length;
        u2 name_index;
        u2 descriptor_index;
        u2 index;
        } local_variable_table[local_variable_table_length] ;
        }
    7. Signature 属性

      • Signature属性是可选的定长属性,位于ClassFile,field_info或method_info结构的属性表中。
      • 在Java语言中,任何类、接口、 初始化方法或成员的泛型签名如果包含了类型变量(Type Variables) 或参数化类型 ( Parameterized Types) ,则Signature 属性会为它记录泛型签名信息。
    8. SourceFile 属性

      • SourceFile属性结构:

        类型 名称 数量 含义
        u2 attribute_name_index 1 属性名索引
        u4 attribute_length 1 属性长度
        u2 sourcefile_index 1 源码文件索引

        可以看到,其长度总是固定的8个字节

    9. 其他属性:

      • Java虚拟机中预定义的属性有20多个,这里就不一一介绍了,通过上面几个属性的介绍,只要领会其精髓,其他属性的解读也是易如反掌。
10、class字节码文件总结
  • 主要介绍了Class文件的基本格式。
  • 随着Java平台的不断发展,在将来,Class文件的内容也一定会做进一步的扩充,但是其基本的格式和结构不会做重大调整。
  • 从Java虚拟机的角度看,通过Class文件,可以让更多的计算机语言支持Java虚拟机平台。因此,Class文件结构不仅仅是Java虛拟机的执行入口,更是Java生态圈的基础和核心。

4、使用javap指令解析Class文件

1、解析字节码的作用
  • 自己分析类文件结构太麻烦了! oracle提供了javap工具。
  • javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
  • 通过反编译生成的汇编代码,我们可以深入的了解java代码的工作机制。比如我们可以查看i++;这行代码实际运行时是先获取变量i的值,然后将这个值加1,最后再将加1后的值赋值给变量i。
  • 通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息。
2、javac -g操作
  • 解析字节码文件得到的信息中,有些信息(如局部变量表、指令和代码行偏移量映射表、常量池中方法的参数名称等等)需要在使用javac编译成class文件时,指定参数才能输出。
  • 比如,你直接javac xx. java, 就不会在生成对应的局部变量表等信息,如果你使用**javac -g xx.java**就可以生成所有相关信息了。如果你使用的eclipse或IDEA,则默认情况下,eclipse、 IDEA在编译时会帮你生成局部变量表、指令和代码行偏移量映射表等信息的。
3、javap的用法
  • javap的用法格式:javap

    • 其中,options就是需要输入的参数选项
    • classes就是你要反编译的class文件。
  • 在命令行中直接输入javap或javap -help可以看到javap的options有如下选项:

    image-20210520022957097

  • 相关代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    public class JavapTest {
    // 五大字段:private、默认、public、protected、final
    private int num;
    boolean flag;
    protected char gender;
    public String info;
    public static final int COUNTS = 1;
    // 静态代码块
    static{
    String url = "www.atguigu.com";
    }
    // 非静态代码块
    {
    info = "java";
    }
    // 两种构造方法:无参(public)与有参(private)
    public JavapTest(){
    }
    private JavapTest(boolean flag){
    this.flag = flag;
    }
    // 四大方法:private、默认、public、protected
    private void methodPrivate(){
    }
    int getNum(int i){
    return num + i;
    }
    protected char showGender(){
    return gender;
    }
    public void showInfo(){
    int i = 10;
    System.out.println(info + i);
    }
    }
  • 这里重组一下:

    • -help / –help / -?:输出此用法消息

    • -version:版本信息,其实是当前javap所在jdk的版本信息,不是class在哪个jdk下生成的。(即javap -version == javap -version xx.class)

      image-20210520023638002

      以上的options与相关class文件无关,为javap本身的options

    • -public:仅显示公共类和成员

      image-20210520023707476

    • -protected:显示受保护的/公共类和成员

      image-20210520024314713

    • -p / -private:显示所有类和成员

      image-20210520024520373

    • package:显示程序包/受保护的/公共类和成员(默认)

      image-20210520024729855

    • -sysinfo:显示正在处理的类的系统信息(路径,大小,日期,MD5散列,源文件名)

      image-20210520024834421

    • constants:显示静态最终常量

      image-20210520025014700

    • 以下options与代码的细节相关:

    • -s:输出内部类型签名

      image-20210520030031188

    • -l:输出行号和本地变量表

      image-20210520030243299

      注意:如果使用的是javac xx. java编译生成的class字节码文件,里面本来就没有本地变量表。因此就是使用-l也看不到本地变量表的信息。

    • -c:对代码进行反汇编

      image-20210520031140075

    • -v / -verbose:输出附加信息(包括行号、本地变量表,反汇编等详细信息)

      • 注意:就算-v也依旧没有私有private的信息。如果需要加上私有private的信息,得到一份最全的信息,就需要加上-p:

        1
        javap -v -p JavapTest.class

      以下很少用到,了解即可。

    • -classpath :指定查找用户类文件的位置

    • -cp :指定查找用户类文件的位置

  • 总结:

    • 一般常用的是-v、-l、-c三个选项:(重要的还是-v和-p
      • javap -l:会输出行号和本地变量表信息。
      • javap -c:会对当前class字节码进行反编译生成汇编代码。
      • javap -v classxx:除了包含-c内容外,还会输出行号、局部变量表信息、常量池等信息。
4、总结
  1. 通过javap命令可以查看一个java类反汇编得到的Class文件版本号、常量池、访问标识、变量表、指令代码行号表等等信息。不显示类索引、父类索引、接口索引集合、()、 ()等结构。其中()、 ()这两个是因为javap太智能,帮我们反编译成了相关的构造方法和静态代码块,在class字节码文件依旧可以看到这两个结构。
  2. 通过对前面例子代码反汇编文件的简单分析,可以发现,一个方法的执行通常会涉及下面几块内存的操作:
    1. java栈中:局部变量表、操作数栈。
    2. java堆:通过对象的地址引用去操作。
    3. 常量池。
    4. 其他如帧数据区(方法返回地址、动态链接、一些附加信息)、方法区的剩余部分等情况,测试中没有显示出来,这里说明一下。
  3. 平常,我们比较关注的是java类中每个方法的反汇编中的指令操作过程,这些指令都是顺序执行的,可以参考官方文档查看每个指令的含义。

2、字节码指令集与解析举例

1、概述

  • Java字节码对于虚拟机,就好像汇编语言对于计算机,属于基本执行指令。
  • Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为**操作码,Opcode) 以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands**) 而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。
  • 由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条
  • 官方文档
  • 熟悉虚拟机的指令对于动态字节码生成、反编译Class文件、Class文件修补都有着非常重要的价值。因此,阅读字节码作为了解Java虚拟机的基础技能,需要熟练掌握常见指令。
1、执行模型

如果不考虑异常处理的话,那么Java虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解:

1
2
3
4
5
6
do{
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置,从字节码流中取出操作码;
if(字节码存在操作数)从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码长度>0);
2、字节码与数据类型
  • 在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。
  • 对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务
    • i代表对int类型的数据操作
    • l代表long
    • s代表short
    • b代表byte
    • c代表char
    • f代表float
    • d代表double
    • Boolean使用的是iconst_0或者iconst1
    • a代表对象
  • 也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令, 它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。
  • 还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的
  • 大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend) 为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend) 为相应的int类型数据。
  • 与之类似,在处理boolean、byte、 short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、 byte、 short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
3、指令分析
  • 由于完全介绍和学习这些指令需要花费大量时间。为了让大家能够更快地熟悉和了解这些基本指令,这里将JVM中的字节码指令集按用途大致分成9类。
    • 加载与存储指令
    • 算术指令
    • 类型转换指令
    • 对象的创建与访问指令
    • 方法调用与返回指令
    • 操作数栈管理指令
    • 比较控制指令
    • 异常处理指令
    • 同步控制指令
  • (说在前面)在做值相关操作时:
    • 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中等取得数据,这些数据(可能是值可能是对象的引用)被压入操作数栈。
    • 一个指令,也可以从操作数栈中取出一到多个值(pop多次),完成赋值、加减乘除、方法传参、系统调用等等操作。

2、加载与存储指令

  • 作用:加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
    • 其中数据压入操作数栈被称为加载
    • 此时的数据可能来源于局部变量表,也有可能来自于常量池(分成具体的两个类指令)
    • 把数据保存在局部变量表当中则被称为存储指令
  • 常用指令
    1. [局部变量压栈指令]将一个局部变量加载到操作数栈:xload、xload_ (其中x为i、l、f、d、a,n为0到3(不一定都是0~3,需要具体分析))(load
    2. [常量入栈指令]将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_W、ldc2_W、aconst_null、iconst_ m1、iconst_ 、lconst_ 、fconst_ 、 dconst_ 。(push、ldc、const
    3. [出栈装入局部变量表指令]将一个数值从操作数栈存储到局部变量表:xstore、xstore_ (其中x为i、l、f、d、a,n为0到3);xastore ( 其中x为i、l、f、d、a、b、c、s)(store
    4. 扩充局部变量表的访问索引的指令:wide。
  • 上面所列举的指令助记符中,有一部分是以尖括号结尾的 (例如iload_ )。这些指令助记符实际上代表了一组指令(例如 iload_ 代表了iload_0、iload_1、iload_2和iload_3这几个指令) 。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们表面上没有操作数,不需要进行取操作数的动作,但操作数都隐含在指令中
    • 比如:
    • iload_0:将局部变量表中索引为0位置上的数据压入操作数栈中。(占一个字节)
    • iload 0:将局部变量表中索引为0位置上的数据压入操作数栈中。(占三个字节)
    • iload 4:将局部变量表中索引为4位置上的数据压入操作数栈中。(占三个字节)
    • 作用:节约空间,减少内存占用
  • 除此之外,它们的语义与原生的通用指令完全一致(例如iload_0的语义与操作数为0时的iload指令语义完全一致)。在尖括号之间的字母指定了指令隐含操作数的数据类型,代表非负的整数, 代表是int类型数据,代表1ong类型,代表float类型, 代表double类型。
  • 操作byte、char、short 和boolean类型数据时,经常用int类型的指令来表示。
0、复习:再谈操作数栈与局部变量表
1、操作数栈(Operand Stacks )

我们知道,Java字节码是Java虛拟机所使用的指令集。因此,它与Java虚拟机基于栈的计算模型是密不可分的。

在解释执行过程中,每当为Java方法分配栈桢时,Java虚拟机往往需要开辟块额外的空间作为操作数栈, 来存放计算的操作数以及返回结果

具体来说便是:执行每一条指令之前,Java虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中

例子:

image-20210520222930557

以加法指令iadd 为例。假设在执行该指令前,栈顶的两个元素分别为int值1和int值2,那么iadd指令将弹出这两个int, 并将求得的和int值3压入栈中。

image-20210520222944844

由于iadd指令只消耗栈项的两个元素,因此,对于离栈顶距离为2的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改。

2、局部变量表(Local Variables)

Java方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中

实际上,Java虚拟机将局部变量区当成一个数组,依次存放this指针(仅非静态方法) ,所传入的参数, 以及字节码中的局部变量。

和操作数栈一样,long类型以及double类型的值将占据两个单元,其余类型仅占据一个单元。一个槽位就是一个单元,占4个字节。

image-20210520223454201

举例:

代码:

1
2
3
4
5
6
7
8
public void foo(long 1, float f) {
{
int i=0;
}
{
String s = "Hello, World";
}
}

对应的图示:(槽位复用)

image-20210520223508191

1、局部变量压栈指令

局部变量压栈指令将给定的局部变量表中的数据压入操作数栈

这类指令大体可以分为:

  • xload_ (x为i、l、f、d、a,n为0到3)
  • xload (x为i、l、f、d、a)

说明:在这里,x的取值表示数据类型。

指令xload_ n表示将第n个局部变量压入操作数栈,比如iload_1、 fload_0、aload_0等指令。其中aload_n表示将一个对象引用压栈。此时该字节码指令占用一个字节(包含一个操作码,一个字节)

指令xload通过指定参数的形式,把局部变量压入操作数栈,当使用这个命令时,表示局部变量的数量可能超过了4个,比如指令iload、fload等。此时该字节码指令占用三个字节(包含一个操作码,一个字节、一个操作数,两个字节)

举例:

代码:

1
2
3
4
5
6
7
8
//1.局部变量压栈指令
public void load(int num, Object obj,long count,boolean flag,short[] arr) {
System.out.println(num);
System.out.println(obj);
System.out.println(count);
System.out.println(flag);
System.out.println(arr);
}

字节码指令执行过程:

image-20210520224306556

2、常量入栈指令

常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为const系列push系列ldc指令

指令const系列:用于对特定的常量入栈,入栈的常量隐含在指令本身里。

指令有: iconst_ <i> (i从-1到5)lconst_ <l> (l从0到1)fconst_ <f> (f从0到2)dconst_ <d> (d从0到1)aconst_null

比如:

  • iconst_m1将-1压入操作数栈;
  • iconst_x (x为0到5)将x压入栈;
  • lconst_0、lconst_1分别将长整数0和1压入栈;
  • fconst_0、fconst_1、 fconst_2分别将浮点数0、1、2压入栈;
  • dconst_0和dconst_1分别将double型0和1压入栈;
  • aconst_null将null压入操作数栈;

从指令的命名上不难找出规律,指令助记符的第一个字符总是喜欢表示数据类型,i表示整数l表示长整数f表示浮点数d表示双精度浮点,习惯上用a表示对象引用。如果指令隐含操作的参数,会以下划线形式给出。

指令push系列:主要包括bipush和sipush。 它们的区别在于接收数据类型的不同,bipush接收8位整数作为参数,sipush接收16位整数,它们都将参数压入栈。

指令ldc系列:如果以上指令都不能满足需求,那么可以使用万能的ldc指令,它可以接收一个8位的参数,该参数指向常量池中的int、float或者String的索引,将指定的内容压入堆栈。

类似的还有ldc_w,它接收两个8位参数,能支持的索引范围大于ldc。

如果要压入的元素是long或者double类型的,则使用ldc2_w指令,使用方式都是类似的。

总结如下:

image-20210520234851493

举例分析:

img

img

注意:常量入栈指令中的n和局部变量压栈指令中的n不一样,常量入栈的n代表数值或者对象,而不是局部变量表中的下标

3、出栈入局部变量表指令

出栈装入局部变量表指令用于将操作数栈中栈项元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值

这类指令主要以store的形式存在,比如xstore(x为i、l、f、d、a)、xstore_n(x为i、l、f、d、a,n为0至3)。

  • 其中,指令istore_n将从操作数栈中弹出一个整数,并把它赋值给局部变量索引n的位置
  • 指令xstore由于没有隐含参数信息,故需要提供一个byte类型的参数类指定目标局部变量表的位置。

说明:

一般说来,类似像store这样的命令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。 但是,为了尽可能压缩指令大小,使用专门的istore_1指令表示将弹出的元素放置在局部变量表第1个位置。类似的还有istore_0、istore_2、 istore_3,它们分别表示从操作数栈顶弹出一个元素,存放在局部变量表第0、2、3个位置。

由于局部变量表前几个位置总是非常常用,因此这种做法虽然增加了指令数量,但是可以大大压缩生成的字节码的体积。如果局部变量表很大,需要存储的槽位大于3,那么可以使用istore指令,外加一个参数,用来表示需要存放的槽位位置。

举例分析:

image-20210521000606437

相关分析:

  1. 首先该方法被调用的时候,形式参数k和d都是有确定的值,由于该方法不是静态方法,所以局部变量表中的第一个位置(槽位)存储this,而第二个位置存储k具体的值,由于老师只是分析,没有调用这个方法,所以老师全部使用的变量名称来代替具体的值,所以明白就好。
  2. 然后第三个和第四个位置储存d具体的值,由于d是double类型,所以需要占据两个槽位,数据已经准备好了,那就来看字节码
    1. 首先iload_1是将局部变量表中下标为1的k值取出来压入操作数栈中
    2. 然后iconst_2是将常量池中的整型值2压入操作数栈,iadd让操作数栈弹出的k值和整型值2执行相加操作
    3. 之后将相加的结果值m压入操作数栈中,请注意老师的画法,在执行弹栈和压栈操作之后,老师并没有删除操作数栈中的k值和2,这是因为老师让我们知道具体的操作过程,所以故意为之,不过真正的操作是弹栈之后k值和2就会从操作数栈中弹出,之后操作数栈中就没有k值和2了,只有m值了
  3. 然后istore_4是将操作数栈中的m值弹出栈,然后放在局部变量表中下标为4的位置
  4. idc2_w #13代表将long型值12压入操作数栈,istore5是将值12弹栈之后放入局部变量表中下标为5的位置,由于12是long型,所以占据两个位置(槽位)
  5. ldc #15代表将字符串atguigu压入操作数栈,astore 7代表将字符串atguigu弹栈之后放入局部变量表中下标为7的位置
  6. idc #16代表将float类型数据10.0压入操作数栈,fstore 8代表将10.0弹出栈,然后放入局部变量表中下标为8的位置
  7. idc2_w #17代表将10.0压入操作数栈,dstore2代表将10.0弹出栈,之后将10.0放入下标为2和3的操作,毕竟这是double类型数据。

槽位复用

img

注意:在方法没有运行的时候,根据字节码文件就可以计算出需要几个槽位

3、算术指令

  1. 作用:算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈

  2. 分类

    • 大体上算术指令可以分为两种:
      • 整型数据进行运算的指令
      • 浮点类型数据进行运算的指令
  3. byte、short、char和boolean类型说明:

    在每一大类中, 都有针对Java虚拟机具体数据类型的专用算术指令。但没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算,都使用int类型的指令来处理。此外,在处理boolean、byte、 short 和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。

    image-20210521002838645

  4. 运算时的溢出
    数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能是一个负数。其实Java虚拟机规范并无明确规定过整型数据溢出的具体结果,仅规定了在处理整型数据时,只有除法指令以及求余指令中当出现除数为0时会导致虚拟机抛出异常ArithmeticException

  5. 运算模式:

    • 向最接近数舍入模式:JVM要求在进行浮点数计算时,所有的运算结果都必须舍入到适当的精度,非精确结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值一样接近, 将优先选择最低有效位为零的;(四舍五入)
    • 向零舍入模式:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果。(截断法)
  6. NaN值使用:

    当一个操作产生溢出时,将会使用有符号的无穷大(Infinity)表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。而且所有使用NaN值作为操作数的算术操作,结果都会返回NaN。

    对于无穷大Infinity和NaN的举例:

    img

    1、所有算术指令

    所有的算术指令包括:

    • 加法指令:iadd、ladd、fadd、dadd
    • 减法指令:isub、lsub、fsub、dsub
    • 乘法指令:imul、lmul、fmul、dmul
    • 除法指令:idiv、ldiv、fdiv、ddiv
    • 求余指令:irem、lrem、frem、drem //remainder:余数
    • 取反指令:ineg、lneg、fneg、dneg //negation:取反
    • 自增指令:iinc
    • 位运算指令, 又可分为:
      • 位移指令:ishl、 ishr、iushr、lshl、lshr、lushr
      • 按位或指令:ior、lor
      • 按位与指令:iand、land
      • 按位异或指令:ixor、lxor
    • 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

相关举例的分析:

  • 对于 i = i + 10 与 i += 10 的区别:(假设i = 100)

    • i = i + 10:

      image-20210521004835960

    • i += 10:

      image-20210521005146519

    结论:

    • 若i一开始为byte类型,与10相加之后转换为int类型;自增10之后编译不报错
    • 如果short i = 10,那么i+=10不是在原位置上加10,而是进行了强转,其中用到了i2s
  • JVM取反(~)操作的具体实现过程:(用异或实现)

    • 先取出操作数压入操作数栈
    • 在将-1压入操作数栈(iconst_m1)
    • 将操作数与-1实现异或(xor)操作
    • 得到的操作数取反后的值在压入操作数栈
1、举例
1
2
3
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}

字节码指令对应的图示:

img

2、一个曾经的案例

代码:

1
2
3
4
5
6
7
public static void main(String[] args) {
int x = 500;
int y = 100;
int a = x / y;
int b = 50;
System.out.println(a + b);
}

字节码对应的内存解析:(省略了前面的解析,主要看一下println()方法:返回值为void)

img

img

注意:

  • 执行System.out.printin()的时候,会在虚拟机栈当中新建一个System.out.printin方法栈帧,55作为参数传入println()方法当中;
  • println()方法栈帧的本地变量表中会放55,这样该方法就可以使用了。
  • println()方法执行结束之后,由于返回值为void,所以字节码指令直接return
  • 进入main方法栈帧之后,也直接执行return。
3、关于i++与++i

没有其他操作(如赋值)的情况下,i++与++i是一样的,没有区别:

1
2
3
4
5
6
//关于(前)++和(后)++
public void method6(){
int i = 10;
i++;
//++i;
}

字节码:image-20210521011319549

与其他运算符(赋值运算符)结合运算的情况下,i++与++i就有区别了:

  • i++:先赋值后自增
  • ++i:先自增后赋值
1
2
3
4
5
6
7
public void method7(){
int i = 10;
int a = i++;

int j = 20;
int b = ++j;
}

字节码:

image-20210521014725284

与println()方法结合的情况:

1
2
3
4
5
public void method8(){
int i = 10;
i = i++;
System.out.println(i);//10
}

字节码:

image-20210521020635532

2、比较指令的说明
  • 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈
  • 比较指令有:dcmpg, dcmpl、 fcmpg、 fcmpl、 lcmp
    • 与前面讲解的指令类似,首字符d表示double类型, f表示float , l表示long。
  • 对于double和float类型的数字,由于NaN的存在,各有两个版本的比较指令。以float为例,有fcmpg和fcmpl两个指令,它们的区别在于在数字比较时,若遇到NaN值,处理结果不同。
  • 指令dcmpl和dcmpg也是类似的,根据其命名可以推测其含义,在此不再赘述。
  • 指令lcmp针对long型整数,由于long型整数没有NaN值,故无需准备两套指令。

举例:

  • 指令fcmpg和fcmpl都从栈中弹出两个操作数,并将它们做比较,设栈顶的元素为v2,栈顶顺位第2位的元素为v1
    • 若v1 = v2,则压入0;
    • 若v1 > v2,则压入1;
    • 若v1 < v2,则压入-1
  • 两个指令的不同之处在于,如果遇到NaN值,fcmpg会压入1,而fcmpl会压入-1

数值类型的数据,才可以谈大小! boolean、引用数据类型不能比较大小。

注意:

  • NaN(Not a Number)表示不是一个数字,比如0.0/0.0得到的可能是1.0(两个数相等),也可能是0.0(0.0是分子),也可能是无穷大(0.0是分母),所以老师给出的解释是NaN代表无法确定是什么数字,只有double和float类型中可能出现NaN的情况,而long类型不会出现NaN,所以只有lcmp
  • 为什么只存在long/float/double的比较指令,而没有char/byte/short/int类型的比较指令?
    • 仔细观察会发现long/float/double的比较指令的最后都会往操作数栈压入一个int类型的值(-1/1/0),而比较指令常常与跳转指令一起使用
    • 而两种指令是通过int类型的值结合在一起使用的
    • 所以也就不必要存在int等类型的比较指令
    • 如果说是有int类型的比较指令的话就是比较条件跳转指令了

4、类型转换指令

类型转换指令说明:

  1. 类型转换指令可以将两种不同的数值类型进行相互转换
  2. 这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
1、宽化类型转换(Widening Numeric Conversions)
1、转换规则

Java虚拟机直接支持以下数值的宽化类型转换(widening numeric conversion,小范围类型向大范围类型的安全转换)。也就是说,并不需要指令执行,包括:

  • 从int类型到long、float或者double类型。对应的指令为:i2li2fi2d
  • 从long类型到float、double类型。对应的指令为:l2fl2d
  • 从float类型到double类型。对应的指令为:f2d

简化为:int –> long –> float –> double

2、精度损失问题
  • 宽化类型转换是不会因为超过目标类型最大值而丢失信息的,例如,从int转换到long,或者从int转换到double,都不会丢失任何信息,转换前后的值是精确相等的。
  • int、long类型数值转换到float,或者long类型数值转换到double时,将可能发生精度丢失——可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近舍入模式所得到的正确整数值。

尽管宽化类型转换实际上是可能发生精度丢失的,但是这种转换永远不会导致Java虚拟机抛出运行时异常

3、补充说明
  • byte、char 和short类型到int类型的宽化类型转换实际上是不存在的。对于byte类型转为int,虚拟机并没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。而将byte转为long时,使用的是i2l,可以看到在内部byte在这里已经等同于int类型处理,类似的还有short类型,这种处理方式有两个特点:
    • 一方面可以减少实际的数据类型,如果为short和byte都准备一套指令,那么指令的数量就会大增,而虛拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过256个,为了节省指令资源,将short和byte当做int处理也在情理之中
    • 另一方面,由于局部变量表中的槽位固定为32位(4个字节,也是int的长度),无论是byte或者short存入局部变量表,都会占用32位空间。从这个角度说,也没有必要特意区分这几种数据类型。
2、窄化类型转换(Narrowing Numeric Conversion)
1、转换规则

Java虚拟机也直接支持以下窄化类型转换

  • 从int类型至byte、short或者char类型。对应的指令有:i2bi2ci2s
  • 从long类型到int类型。对应的指令有:l2i
  • 从float类型到int或者long类型。对应的指令有:f2if2l
  • 从double类型到int、long或者float类型。 对应的指令有:d2id2ld2f

注意:从float、double、long等类型往byte、short、char类型转换的时候,需要先把前面几种类型转换成int类型,然后在从int类型转换到后面这几种类型,所以int类型相等于一种过渡类型。

2、精度损失问题
  • 窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程很可能会导致数值丢失精度
  • 尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常
3、补充说明
  1. 当将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:
    • 如果浮点值是NaN,那转换结果就是int或long类型的0
    • 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。否则,将根据v的符号,转换为T所能表示的最大或者最小正数
  2. 当将一个double类型窄化转换为float类型时,将遵循以下转换规则:
    • 通过向最接近数舍入模式舍入一个可以使用float类型表示的数字。最后结果根据下面这3条规则判断:
      • 如果转换结果的绝对值太小而无法使用float来表示, 将返回float类型的正负零
      • 如果转换结果的绝对值太大而无法使用float来表示,将返回float类型的正负无穷大
      • 对于double类型的NaN值将按规定转换为float类型的NaN值。

举例:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void downCast5(){
double d1 = Double.NaN; //0.0 / 0.0
int i = (int)d1;
System.out.println(d1); // NaN
System.out.println(i); // 0

double d2 = Double.POSITIVE_INFINITY;
long l = (long)d2;
int j = (int)d2;
System.out.println(l); // 9223372036854775807
System.out.println(Long.MAX_VALUE); // 92233720368547 75807
System.out.println(j); // 2147483647
System.out.println(Integer.MAX_VALUE); // 2147483647

float f = (float)d2;
System.out.println(f); // infinity

float f1 = (float)d1;
System.out.println(f1); // NaN
}

5、对象的创建与访问指令

Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作,可进一步细分为创建指令字段访问指令数组操作指令类型检查指令

1、创建指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:

  1. 创建类实例的指令:

    • 创建类实例的指令:new

      • 接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完成后,将对象的引用压入栈

      例子:

      image-20210521031608842

  2. 创建数组的指令:

    • 创建数组的指令:newarray、 anewarray、 multianewarray

      • newarray:创建基本类型数组
      • anewarray:创建引用类型数组
      • multianewarray:创建多维数组

      例子:

      image-20210521032029716

上述创建指令可以用于创建对象或者数组,由于对象和数组在Java中的广泛使用,这些指令的使用频率也非常高。

2、字段访问指令

对象创建后,就可以通过对象访问指令获取对象实例数组实例中的字段或者数组元素

  • 访问类字段(static字段,或者称为类变量)的指令getstaticputstatic
  • 访问类实例字段(非static字段,或者称为实例变量)的指令getfieldputfield

举例1:
以getstatic指令为例,它含有一个操作数,为指向常量池的Fieldref索引,它的作用就是获取Fieldref指定的对象或者值,并将其压入操作数栈。

1
2
3
public void sayHello() {
System.out.println("hello");
}

对应的字节码指令:

1
2
3
4
0 getstatic #8 <java/lang/System.out>
3 ldc #9 <hello>
5 invokevirtual #10 <java/io/PrintStream.println>
8 return

图示:

image-20210521033428524

举例2:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
public void setOrderId(){
Order order = new Order();
order.id = 1001;
System.out.println(order.id);

Order.name = "ORDER";
System.out.println(Order.name);
}
class Order{
int id;
static String name;
}

字节码指令执行过程:

image-20210521034806188

注意:getxxx是入栈,而putxxx是出栈

3、数组操作指令
1、数组操作指令

数组操作指令主要有:xastorexaload指令。具体为:

  • 一个数组元素加载到操作数栈的指令baloadcaloadsaloadialoadlaloadfaloaddaloadaaload
  • 一个操作数栈的值存储到数组元素中的指令bastorecastoresastoreiastorelastorefastoredastoreaastore

即:

image-20210521171308285

  • 取数组长度的指令arraylength
    • 该指令弹出栈顶的数组元素,获取数组的长度,将长度压入栈
2、说明
  • 一个操作数栈的值存储到数组元素中的指令,即xastore指令与xstore指令的区别:

    • xstore指令是将值存放进局部变量表里面
    • xastore指令是将值存放进堆空间中对应的数组元素里面
  • 指令xaload表示将数组的元素压栈,比如saload、caload分别表示压入short数组和char数组。指令xaload在执行时,要求操作数中栈顶元素为数组索引i,栈顶顺位第2个元素为数组引用a,该指令会弹出栈顶这两个元素,并将a[i]重新压入栈。

  • xastore则专门针对数组操作,以iastore为例, 它用于给一个int数组的给定索引赋值。在iastore执行 ,操作数栈顶需要以此准备3个元素:

    • 索引
    • 数组引用

    iastore会弹出这3个值,并将值赋给数组中指定索引的位置。

    image-20210521171926923

4、类型检查指令

检查类实例或数组类型的指令:instanceofcheckcast

  • 指令checkcast用于检查类型强制转换是否可以进行。如果可以进行,那么checkcast指令不会改变操作数栈,否则它会抛出ClassCastException异常
  • 指令instanceof用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈

6、方法调用与返回指令

1、方法调用指令

方法调用指令:invokevirtualinvokeinterfaceinvokespecialinvokestaticinvokedynamic

以下5条指令用于方法调用:

  • invokevirtual指令用于调用对象的实例方法根据对象的实际类型进行分派(虚方法分派),支持多态。(可被子类重写)这也是Java语言中最常见的方法分派方式。
    • invokevirtual是调用类中的非静态普通方法,而这种实例方法可能调用的是子类重写的非静态普通方法,比如A a = new B();a.hello(),其中B类继承A类,并且B类重写了A类中的hello()方法,这种情况下就是invokevirtual了,但是有可能该类没有子类,调用的就是本类中的非静态普通方法,这种情况也是invokevirtual了
  • invokeinterface指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出适合的方法进行调用
    • invokeinterface是对接口而言的,用属于接口类型的对象调用方法的时候就是这个
  • invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)**、私有方法父类方法。这些方法都是静态类型绑定的不会在调用时进行动态派发。(不能被子类重写**)
    • invokespecial只有构造器、私有方法、super.方法名()调用父类方法这几种情况,其中调用父类方法这种情况可能出现其直接父类没有该方法,那就可以调用其父类继承的父类中的该方法,最终找到一个方法调用就是了。
  • invokestatic指令用于调用命名类中的类方法(static方法)。这是静态绑定的
    • invokestatic是调用static静态方法,无论是使用对象.静态方法名()还是类名.静态方法名()都是invokestatic,也不难理解
  • invokedynamic:调用动态绑定的方法,这个是JDK 1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面4条调用指令的分派逻辑都固化在java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。具体可看博客:关于invokedynamic

注意:

  • 当一个方法既是私有方法,又是静态方法。使用的是invokestatic指令
  • 关于在接口当中的静态方法与默认方法
    • 静态方法:使用的是invokestatic指令
    • 默认方法:使用的是invokeinterface指令

总结:

  • 除了static的接口的方法都是invokeinterface,如果是static那么都是invokestatic。
  • 用static修饰的方法都是使用invokestatic
  • 如果是用多态的话使用的是invokevirtual
  • 如果是强转成接口类型的方法使用的是invokeinterface
2、方法返回指令

方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的:

  • 包括ireturn (当返回值是boolean、 byte、char、short和int类型时使用)、lreturnfreturndreturnareturn
  • 另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。

image-20210521200452707

说明:

  • 通过ireturn指令,将当前函数操作数栈的顶层元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所有在当前函数操作数栈中的其他元素都会被丢弃
  • 如果当前返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令,退出临界区(解锁的作用)。最后,会丢弃当前方法的整个帧,恢复调用者的帧,并将控制权转交给调用者。
  • 当返回的变量与返回值不是同一个基本数据类型的话,会有一个使用类型转换指令的过程。

举例:

代码:

1
2
3
4
5
6
public int methodReturn( ){
int i = 500;
int j = 200;
int k = 50;
return(i+j)/k;
}

image-20210521200909546

7、操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令。

这类指令包括如下内容:

  • 将一个或两个元素从栈顶弹出,并且直接废弃:pop, pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈项:dup, dup2, dup_x1dup2_x1, dup_x2, dup2_x2;
  • 栈最顶端的两个Slot数值位置交换swap
    • Java虛拟机没有提供交换两个64位数据类型(long、double) 数值的指令。
  • 指令nop,是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做。这条指令一般可用于调试、占位等。

这些指令属于通用型,对栈的压入或者弹出无需指明数据类型

说明:

  • 不带_x的指令是复制栈顶数据并压入栈顶。包括两个指令,dupdup2
    • dup的系数代表要复制的Slot个数。
    • dup开头的指令用于复制1个Slot的数据。
      • 例如1个int或1个reference类型数据
    • dup2开头的指令用于复制2个Slot的数据。
      • 例如1个long,或2个int,或1个int+1个float类型数据
  • 带_ x的指令是复制栈顶数据并插入栈顶以下的某个位置。共有4个指令,dup_x1dup2_x1dup_x2dup2_x2
    • 对于带x的复制插入指令,只要将指令的dup和x的系数相加,结果即为需要插入的位置。因此:
      • dup_x1插入位置:1+1=2, 即栈顶2个Slot下面
      • dup_x2插入位置:1+2=3, 即栈顶3个Slot下面
      • dup2_x1插入位置:2+1=3, 即栈顶3个Slot下面
      • dup2_x2插入位置:2+2=4, 即栈顶4个Slot下面
  • pop:将栈项的1个Slot数值出栈。例如1个short类型数值
  • pop2:将栈顶的2个Slot数值出栈。例如1个double类型数值,或者2个int类型数值

例子:

image-20210521223947568

8、控制转义指令

程序流程离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上可以分为:

  1. 比较指令(在算术指令那里)
  2. 条件跳转指令
  3. 比较条件跳转指令
  4. 多条件分支跳转指令
  5. 无条件跳转指令等。
1、条件跳转指令

条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前。一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。

条件跳转指令有:ifeqifltifleifneifgtifge, ifnull, ifnonnull

这些指令都接收两个字节的操作数,用于计算跳转的位置(16位符号整数作为当前位置的offset)。

它们的统一含义为:弹出栈顶元素,测试它是否满足某一条件,如果满足条件,则跳转到给定位置

具体说明:

image-20210521230336692

注意:

  1. 与前面运算规则一致:
    • 对于boolean、byte、char、short类型的条件分支比较操作,都是使用int类型的比较指令完成
    • 对于long、float、double类型的条件分支比较操作,则会先执行相应类型的比较运算指令,运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。
  2. 由于各类型的比较最终都会转为int类型的比较操作,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的。

例子:

image-20210521232741411

注意:

  1. 对于float、double、long类型的比较,它们比较之后生成的是int类型的0、1、-1,这个过程可以使用比较指令和条件跳转指令来完成,虽然得到的是int类型的值,但是System.out.println(XXX)中的值是布尔类型,你可以在jclasslib中的常量池信息中看到写的是Z,代表布尔值类型。
  2. int类型值(包含byte、char、short)比较和对象类型值比较需要使用比较条件跳转指令
  3. 在比较当中,跳转指令的选择与代码里面的条件判断恰好相反
    • 如题当中代码比较的是f1 < f2,而字节码当中却使用了ifge指令(f1 >= f2)
    • 原因是该指令是跳转指令:也就是当满足条件才跳转,不满足的话就只是顺序执行。所以与代码执行顺序相反(代码是满足条件就顺序执行,不满足才跳转到相应的执行语句)
2、比较条件跳转指令

比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。

这类指令有:if_icmpeqif_icmpneif_icmpltif_icmpgtif_icmpleif_icmpge、(之前的都是与int相关的比较条件跳转指令)if_acmpeqif_acmpne。其中指令助记符加上”if”后,以字符”i”开头的指令针对int型整数操作(也包括short和byte类型), 以字符”a”开头的指令表示对象引用的比较。

具体说明:

image-20210521234455626

这些指令都接收两个字节的操作数作为参数,用于计算跳转的位置。同时在执行指令时,栈顶需要准备两个元素进行比较。
指令执行完成后,栈顶的这两个元素被清空,且没有任何数据入栈。如果预设条件成立,则执行跳转,否则,继续执行下一条语句

注意:

  • 上面所说的后者是栈顶元素,而前者是栈顶下面的元素
  • 对于float、double、long类型的比较,它们比较之后生成的是int类型的0、1、-1,这个过程可以使用比较指令和条件跳转指令来完成。
  • 而int类型值(包含byte、char、short)比较和对象类型值比较需要使用比较条件跳转指令,
  • 其中对象类型值不是比较的地址,就是比较对象中的某些字段值,这又归咎到float、double、long、int类型的比较中比较条件跳转指令。
3、多条件分支跳转

多条件分支跳转指令是专为switch-case语句设计的,主要有tableswitchlookupswitch

image-20210522000649232

从助记符上看,两者都是switch语句的实现,它们的区别:

  • tableswitch要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数index,可以立即定位到跳转偏移量位置,因此效率比较高
  • lookupswitch内部存放着各个离散的case-offset对,每次执行都要搜索全部的case-offset对,找到匹配的case值,并根据对应的offset计算跳转地址,因此效率较低

指令tableswitch:

指令tableswitch的示意图如下图所示。由于tableswitch的case值是连续的,因此只需要记录最低值和最高值,以及每一项对应的offset偏移量,根据给定的index值通过简单的计算即可直接定位到offset。

image-20210522001143999

举例:

image-20210522002407044

注意:代码的break语句对应的就是字节码指令里的goto指令,无条件跳转到return处。如果代码没有加上break语句的话就会发生switch穿透,其实对应到字节码指令就是缺少goto指令跳转到return,只能往下顺序执行。

指令lookupswitch:

指令lookupswitch处理的是离散的case值,但是出于效率考虑,将case-offset对按照case值大小排序,给定index时需要查找与index相等的case,获得其offset,如果找不到则跳转到default。指令lookupswitch如下图所示。

image-20210522001304745

举例:

image-20210522003748294

关于String的switch语句:(使用的是指令lookupswitch和方法hashcode与equal)

image-20210522004730384

4、无条件跳转

目前主要的无条件跳转指令为goto

  • 指令goto接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处
  • 如果指令偏移量太大,超过双字节的带符号整数的范围,则可以使用指令goto_W,它和goto有相同的作用,但是它接收4个字节的操作数,可以表示更大的地址范围。
  • 指令jsrjsr_Wret虽然也是无条件跳转的,但主要用于try-finally语句,且已经被虚拟机逐渐废弃,故不在这里介绍这两个指令。

image-20210522005055555

举例:

通过goto指令与条件比较指令实现循环

image-20210522005743194

注意:

  • 这里使用的i为int类型,在代码执行i++的时候,字节码指令使用的是iinc 1 by 1;直接在局部变量表里面加。
  • 如果使用的i为double类型,在代码执行i++的时候,字节码使用的是 dload_x + dconst_1 + dadd + dastore_x 的指令组合实现的,需要在操作数栈中相加。(注意这里dload_x与dastore_x当中的x是一样的)
  • 如果使用的i为short类型,在代码执行i++的时候,字节码使用的是 iload_x + iconst_1 + iadd + i2s + istore_x 的指令组合实现的,需要在操作数栈中相加,并且在相加之后还需要将值从int窄化为short类型,才能存进局部变量表当中。
  • 所以用于循环遍历的变量尽量使用int,能增加系统的执行速度(调优)

9、异常处理指令

异常及异常的处理:
过程一:异常对象的生成过程 —> throw (手动/自动) —> 指令: athrow
过程二:异常的处理:抓抛模型。try-catch-finally —>使用异常表

1、抛出异常指令
  1. athrow指令

    • 在Java程序中显示抛出异常的操作(throw语句)都是由athrow指令来实现。
    • 除了使用throw语句显示抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出
      • 例如,在之前介绍的整数运算时,当除数为零时,虚拟机会在idivldiv指令中抛出ArithmeticException异常。
  2. 注意:

    • 正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者操作数栈上
    • 如果使用throw new 异常名称()这种形式来抛出异常,那就会在代码中出现athrow指令,
    • 在方法上面添加throw异常名称这种形式来抛出异常,然后使用jclasslib的时候就会出现在方法下面多出现一个属性Exceptions。
  3. 举例:

    • throw new 异常名称():

      image-20210522014446997

    • 在方法上面添加throw异常名称:

      img

    • 运行时异常没有athrow:

      image-20210522014923019

2、异常处理与异常表
  1. 处理异常:
    • 在Java虚拟机中,处理异常(catch语句)**不是由字节码指令来实现的(早期使用jsr、ret指令),而是采用异常表来完成的**。
  2. 异常表:
    • 如果一个方法定义了一个try-catch或者try-finally的异常处理,就会创建一个异常表。它包含了每个异常处理或者finally块的信息
    • 异常表保存了每个异常处理信息。比如:
      • 起始位置
      • 结束位置
      • 程序计数器记录的代码处理的偏移地址
      • 被捕获的异常类在常量池中的索引
  • 当一个异常被抛出时,JVM会在当前的方法里寻找一个匹配的处理,如果没有找到,这个方法会强制结束并弹出当前栈帧,并且异常会重新抛给上层调用的方法(在调用方法栈帧)。如果在所有栈帧弹出前仍然没有找到合适的异常处理,这个线程将终止。
  • 如果这个异常在最后一个非守护线程里抛出,将会导致JVM自己终止,比如这个线程是个main线程
  • 不管什么时候抛出异常,如果异常处理最终匹配了所有异常类型,代码就会继续执行。在这种情况下,如果方法结束后没有抛出异常,仍然执行finally块,在return前,它直接跳到finally块来完成目标。

异常表如下所示:

img

异常表的含义是如果在Start PC和End PC之间(大于等于Start PC,小于End PC(左闭右开))出现对应的Catch Type异常问题(出现异常就匹配对应的异常),将会在操作数栈中压入相应的异常类对象,之后跳转到Handler PC的位置去执行对应的字节码指令。

注意:

当异常出现的时候也会压入操作数栈,之后还会存储局部变量表中

举例1:

image-20210522020333543

举例2:

image-20210522021858220

10、同步控制指令

组成:

  • java虚拟机支持两种同步结构:
    • 方法级的同步
    • 方法内部一段指令序列的同步
  • 这两种同步都是使用monitor来支持的。
1、方法级的同步(添加synchronized的方法)

方法级的同步:是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法;

当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED访 问标志是否设置。

  • 如果设置了,执行线程将先持有同步锁,然后执行方法。最后在方法完成(无论是正常完成还是非正常完成)时释放同步锁
  • 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁。
  • 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放

说明:

这段代码和普通的无同步操作的代码没有什么不同,没有使用monitorenter和monitorexit进行同步区控制。这是因为,对于同步方法而言,当虚拟机通过方法的访问标示符判断是一个同步方法时,会自动在方法调用前进行加锁,当同步方法执行完毕后,不管方法是正常结束还是有异常抛出,均会由虚拟机释放这个锁。因此,对于同步方法而言,monitorentermonitorexit指令是隐式存在的,并未直接出现在字节码中。

举例:一个方法无论是否添加synchronized,你都无法在字节码中看出区别

img

是否是同步方法在字节码文件中你是无法看出区别的,但是可以在方法访问标识中看出区别

2、方法内部一段指令序列的同步

同步一段指令集序列:通常是由java中的synchronized语句块来表示的。jvm的指令集有monitorentermonitorexit两条指令来支持synchronized关键字的语义。

  • 当一个线程进入同步代码块时,它使用monitorenter指令请求进入。如果当前对象的监视器计数器为0,则它会被准许进入
  • 若为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。
  • 当线程退出同步块时,需要使用monitorexit声明退出。在Java虚拟机中,任何对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有后,对象处于锁定状态。
  • 指令monitorentermonitorexit在执行时,都需要在操作数栈项压入对象,之后monitorentermonitorexit的锁定和释放都是针对这个对象的监视器进行的。

下图展示了监视器如何保护临界区代码不同时被多个线程访问,只有当线程4离开临界区后,线程1、2、3才有可能进入。

image-20210522024144096

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。

为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令

举例:

img

过程分析:

  1. 操作数栈中的对象和monitorenter结合起来可以让线程获取锁,做法就是让对象的监视器标记从0变成1,这就代表该线程上锁了
  2. 然后在操作数栈的aload_1monitorexit结合起来就可以让线程解锁,做法就是让对象的监视器标记从1变成0
  3. 这个解锁需要在方法退出之前完成,如果方法执行过程中出现了任何异常,将会跳到异常处理的字节码处执行相关代码;
  4. 如果异常处理的字节码部分出现了问题,那就重新执行异常处理的字节码

这些内容都在异常表中写的很明确,其中异常表也在上面截图中。


3、类的加载过程详解

1、概述

类的加载过程详解:这里的类指的是Class。泛指java当中的类Class、接口Interface、注解类Annotation、枚举Enum等等。

在Java中数据类型分为基本数据类型和引用数据类型:

  • 基本数据类型由虚拟机预先定义
  • 引用数据类型则需要进行类的加载

按照Java虛拟机规范,从class文件到加载到内存中的类,到类卸载出内存为止,它的整个生命周期包括如下7个阶段:

中篇_第3章:类的生命周期

其中,验证、准备、解析3个部分统称为链接(Linking)

注意:我们所说的加载完毕包括:加载、链接、初始化三个阶段都完成之后类进入方法区中

程序中类的使用过程看:

中篇_第3章:类的加载过程

2、过程一:Loading(加载)阶段

1、加载完成的操作
1、加载的理解

所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象

所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。

反射的机制即基于这一基础。 如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。

2、加载完成的操作

加载阶段,简言之,查找并加载类的二进制数据,生成Class的实例

在加载类时,Java虚拟机必须完成以下3件事情:

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口
2、二进制流的获取方式

对于类的二进制数据流,虚拟机可以通过多种途径产生或获得:(只要所读取的字节码符合JVM规范即可)

  • 虚拟机可能通过文件系统读入一个class后缀的文件(最常见)
  • 读入jar、zip等归档数据包,提取类文件。
  • 事先存放在数据库中的类的二进制数据
  • 使用类似于HTTP之类的协议通过网络进行加载(序列化与反序列化)
  • 运行时生成段Class的二进制信息等

在获取到类的二进制信息后,Java虛拟机就会处理这些数据,并最终转为一个java.lang.Class的实例

如果输入数据不是ClassFile的结构, 则会抛出ClassFormatError

3、类模型与Class实例的位置
  1. 类模型的位置:加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:元空间)。

  2. Class实例的位置:

    • 类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构;
    • 该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。(instanceKlass –> mirror:Class的实例)
  3. 图示:

    中篇_第3章:Class实例

  4. 说明:

    • Class类的构造方法是私有的,只有JVM能够创建
    • java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据、入口
    • 通过Class类提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等信息。
4、数组类的加载
  • 创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的
  • 数组的元素类型仍然需要依靠类加载器去创建
  • 创建数组类(下述简称A)的过程:
    1. 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型
    2. JVM使用指定的元素类型和数组维度来创建新的数组类
  • 如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public

3、过程二:Linking(链接)阶段

1、环节1:链接阶段之Verification(验证)

当类加载到系统后,就开始链接操作,验证是链接操作的第一步。

它的目的是保证加载的字节码是合法、合理并符合规范的

验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查,如图所示:

中篇_第3章:验证阶段的检查

整体说明:验证的内容则涵盖了类数据信息的格式验证语义检查字节码验证,以及符号引用验证等。

  • 其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。
  • 格式验证之外的验证操作将会在方法区中进行。

链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查。(磨刀不误砍柴工)

具体说明

  1. 格式验证

    • 是否以魔数0xCAFEBABE开头
    • 主版本和副版本号是否在当前Java虚拟机的支持范围内
    • 据中每一个项是否都拥有正确的长度等。
  2. Java虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:

    • 是否所有的类都有父类的存在(在Java里, 除了object外, 其他类都应该有父类)
    • 是否一些被定义为final的方法或者类被重写或继承
    • 非抽象类是否实现了所有抽象方法或者接口方法
    • 是否存在不兼容的方法。比如:
      • 方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度
      • abstract情况下的方法,就不能是final的了
  3. Java虚拟机还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:

    • 在字节码的执行过程中,是否会跳转到一条不存在的指令
    • 函数的调用是否传递了正确类型的参数
    • 变量的赋值是不是给了正确的数据类型等

    栈映射帧(StackMapTable)**就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虛拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。在前面3次检查中,已经排除子文件格式错误语义错误以及字节码的不正确性但是依然不能确保类是没有问题的**。

    image-20210522144342032

  4. 校验器还将进行符号引用的验证。Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError此阶段在解析环节才会执行

2、环节2:链接阶段之Preparation(准备)

准备阶段(Preparation),简言之,**为类的静态变量分配内存,并将其初始化为默认值**。

当一个类验证通过时,虚拟机就会进入准备阶段。在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值

Java虛拟机为各类型变量默认的初始值如表所示:

类型 默认初始值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0
char \u0000
boolean false
reference null

注意:Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,故对应的,boolean的默认值就是false

注意:

  1. **这里不包含基本数据类型的字段用static final修饰(常量)的情况, 因为final在编译的时候就会分配了,准备阶段会显式赋值**。
  2. 注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中,会在使用类时候才会初始化。
  3. 准备阶段并不会像初始化阶段中那样会有初始化或者代码被执行

对注意中的第1点与第3点分析:

  • 基本数据类型:
    • 非final修饰的变量,在准备环节进行默认初始化赋值
    • **final修饰以后就是常量了,不能在进行赋值,所以在编译阶段初始化赋值,然后在准备阶段就会显示赋值**。
  • 如果使用字面量的方式定义一个字符串的常量的话(public static final String constStr = “CONST”;),也是在编译阶段初始化赋值,然后在准备阶段就会显示赋值
  • 引用数据类型的静态常量,尤其是new String(“XXX”)这种形式,如:public static final String constStr1 = new String(“CONST”);都是在初始化中的中进行显示赋值的(即在方法当中进行初始化的显示赋值,是在初始化阶段使用代码的方式才会进行的显示赋值,然而在准备阶段不会有代码的执行)
  • 如果在static静态代码块中具有显示赋值操作(定义的后面没有赋值),那肯定就是在初始化中的方法<clinit>中显示赋值
3、环节3:链接阶段之Resolution(解析)

在准备阶段完成后,就进入了解析阶段。

解析阶段(Resolution),简言之:将类、接口、字段和方法的符号引用转为直接引用

具体描述

符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。

举例:输出操作System.out.println()对应的字节码:invokevirtual #24 <java/io/PrintStream. println>

中篇_第3章:输出语句的符号引用

以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。

小结

所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构

不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpotVM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行

字符串的复习

最后,再来看一下CONSTANT_ String的解析。 由于字符串在程序开发中有着重要的作用,因此,读者有必要了解一下。

String在Java虚拟机中的处理。当在Java代码中直接使用字符串常量时,就会在类中出现CONSTANT_String,它表示字符串常量,并且会引用一个CONSTANT_UTF8的常量项。在Java虚拟机内部运行中的常量池中,会维护一张字符串拘留表(intern),它会保存所有出现过的字符串常量,并且没有重复项。只要以CONSTANT_String形式出现的字符串也都会在这张表中。使用String.intern()方法可以得到一个字符串在拘留表中的引用,因为该表中没有重复项,所以任何字面相同的字符串的String.intern()方法返回总是相等的

4、过程三:Initialization(初始化)阶段

初始化阶段,简言之:为类的静态变量赋于正确的初始值(显示赋值)

具体描述:

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。(即: 到了初始化阶段,才真正开始执行类中定义的Java 程序代码。)

初始化阶段的重要工作是执行类的初始化方法:()方法

  • 该方法仅能由Java编译器生成并由JVM调用, 程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。
  • 它是由**类静态成员的赋值语句以及static语句块合并产生的**。

说明

  1. 在加载一个类之前,虚拟机总是会试图加载该类的父类,因此父类的总是在子类之前被调用。也就是说,父类的static块优先级高于子类。口诀:由父及子,静态先行

  2. Java编译器并不会为所有的类都产生()初始化方法。哪些类在编译为字节码后,字节码文件中将不会包含()方法:

    • 一个类中并没有声明任何的类变量,也没有静态代码块时

      1
      2
      //场景1:对应非静态的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
      public int num = 1;
    • 一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作

      1
      2
      //场景2:静态的字段,没有显式的赋值,不会生成<clinit>()方法
      public static int num1;
    • 一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式

      1
      2
      //场景3:比如对于声明为static final的基本数据类型的字段,不管是否进行了显式赋值,都不会生成<clinit>()方法
      public static final int num2 = 1;
1、static与final的搭配问题(显示初始化问题)

问题研究:使用static + final修饰的字段的显式赋值的操作,到底是在哪个阶段进行的赋值?

  • 情况1:在链接阶段的准备环节赋值
  • 情况2:在初始化阶段()中赋值

实例代码:

1
2
3
4
5
6
7
8
9
10
public class InitializationTest2 {
public static int a = 1;//在初始化阶段<clinit>()中赋值
public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化阶段<clinit>()中赋值

public static final String s0 = "helloworld0";//在链接阶段的准备环节赋值
public static final String s1 = new String("helloworld1");//在初始化阶段<clinit>()中赋值
}

初步结论:

  • 在链接阶段的准备环节赋值的情况:
    • 对于基本数据类型的字段来说,如果使用static final修饰,则显式赋值(直接赋值常量,而非调用方法进行动态赋值)通常是在链接阶段的准备环节进行
    • 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行
  • 在初始化阶段()中赋值的情况:
    • 排除上述的在准备环节赋值的情况之外的情况。

实例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InitializationTest2 {
public static int a = 1;//在初始化阶段<clinit>()中赋值
public static final int INT_CONSTANT = 10;//在链接阶段的准备环节赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);//在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000);//在初始化阶段<clinit>()中赋值

public static final String s0 = "helloworld0";//在链接阶段的准备环节赋值
public static final String s1 = new String("helloworld1");//在初始化阶段<clinit>()中赋值

public static String s2 = "helloworld2";

public static final int NUM1 = new Random().nextInt(10);//在初始化阶段<clinit>()中赋值
}

最终结论:

  • 在链接阶段的准备环节赋值的情况:
    • 使用static+final修饰,并且进行显示赋值(定义的时候后面就已经附了确定的初始值),还不涉及到方法或者构造器调用的基本数据类型或者String类型字面量(“XXX”这种形式,而不是new String(“XXX”)这种形式)的字段,将在准备中的链接阶段进行显示赋值
    • 对于准备阶段就完成赋值的,**其字段下面的有属性ConstantValue**,在初始化阶段()中赋值的字段是没有属性ConstantValue的。
  • 在初始化阶段()中赋值的情况:
    • 已经进行显示赋值的静态常量static+final修饰)(包括引用类型,尤其是new String(“XXX”)这种类型的,还有调用其他方法获得的值,比如new Random().nextInt(10)等)或者静态变量(这是肯定在初始化方法中显示赋值)都将在初始化中的方法中进行显示赋值
  • 使用static + final修饰,且显示赋值中不涉及到方法构造器调用的基本数据类型String类型的显式赋值,是在链接阶段的准备环节进行。

补充:

  1. 换个角度思考下,只有在常量池中已经确定的值,才会在链接中的准备阶段赋值,像对象在常量池存储的一般都是符号引用,而并非是对象,仅仅是描述对象一个字符串,真正的对象还需通过字节码进行new,这一new不就得用类构造方法,不就得需要在初始化阶段()中赋值了吗
  2. 计算中1/0,即public static final int INT_CONSTANT = 1/0,也不能在链接阶段的准备环节赋值,因为它会要抛异常,需要使用到代码
  3. 这里说的能够用常量池中数据表示是按照结果论,所以2/2这种的结果是一个int值可以表示
  4. 而new String(“”)是个特例,String的引用是可以在常量池中表示的,但是new String是在初始化阶段赋值
  5. 也不能单纯看是不是字面量,如果是static final Integer a = 1,也是在初始化阶段()中赋值,只能说能尽量能在准备阶段赋值的就在准备阶段,实在不行才在初始化阶段()中赋值
  6. 另外一个角度:在链接阶段的准备环节赋值是不能动用代码的,因为真正开始执行类中定义的Java 程序代码是在到了初始化阶段才开始的。因此:可以在不使用java代码就能进行显示赋值的就在链接阶段的准备环节进行赋值,而赋值需要java代码参与的就只能在初始化阶段()中进行显示赋值
2、()的线程安全性
  • 对于()方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性。

  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。

  • 正是因为函数( )带锁线程安全的,因此,如果在一个类的()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息

    • 函数( )带的锁是隐式的锁,并不是使用sychronized进行加锁的。

      image-20210522204628093

  • 如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行()方法了。那么,当需要使用这个类时虚拟机会直接返回给它已经准备好的信息。(一个类只需要加载一次

死锁的相关代码:(使用两个进程让A、B交叉加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class StaticA {
static {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
Class.forName("com.atguigu.java1.StaticB");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("StaticA init OK");
}
}
class StaticB {
static {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
try {
Class.forName("com.atguigu.java1.StaticA");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println("StaticB init OK");
}
}
public class StaticDeadLockMain extends Thread {
private char flag;

public StaticDeadLockMain(char flag) {
this.flag = flag;
this.setName("Thread" + flag);
}
@Override
public void run() {
try {
Class.forName("com.atguigu.java1.Static" + flag);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
System.out.println(getName() + " over");
}
public static void main(String[] args) throws InterruptedException {
StaticDeadLockMain loadA = new StaticDeadLockMain('A');
loadA.start();
StaticDeadLockMain loadB = new StaticDeadLockMain('B');
loadB.start();
}
}

由此得出结论:

  • 编写代码的时候要尽量避免让类进行交叉加载或循环加载/依赖
3、类的初始化情况:主动使用vs被动使用

Java程序对类的使用分为两种:

  • 主动使用(调用了()方法)
  • 被动使用(没有调用了()方法)

注意:没有调用了()方法只是没有进入初始化阶段,并不代表该类没有加载

1、主动使用

Class只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件地装载Class类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:(即: 如果出现如下的情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已经完成。)

  1. 当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      public class ActiveUse1 {
      public static void main(String[] args) {
      // 使用new关键字
      Order order = new Order();
      }
      //序列化的过程:
      @Test
      public void test1() {
      ObjectOutputStream oos = null;
      try {
      oos = new ObjectOutputStream(new FileOutputStream("order.dat"));

      oos.writeObject(new Order());
      } catch (IOException e) {
      e.printStackTrace();
      } finally {
      try {
      if (oos != null)
      oos.close();
      } catch (IOException e) {
      e.printStackTrace();
      }
      }

      }
      //反序列化的过程:(验证)
      @Test
      public void test2() {
      ObjectInputStream ois = null;
      try {
      ois = new ObjectInputStream(new FileInputStream("order.dat"));

      Order order = (Order) ois.readObject();
      } catch (IOException e) {
      e.printStackTrace();
      } catch (ClassNotFoundException e) {
      e.printStackTrace();
      } finally {
      try {
      if (ois != null)
      ois.close();
      } catch (IOException e) {
      e.printStackTrace();
      }
      }
      }
      }
      class Order implements Serializable{
      static {
      System.out.println("Order类的初始化过程");
      }
      }
  2. 当调用类的静态方法时,即当使用了字节码invokestatic指令。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @Test
      public void test3(){
      Order.method();
      }
      class Order implements Serializable{
      static {
      System.out.println("Order类的初始化过程");
      }
      public static void method(){
      System.out.println("Order method()....");
      }
      }
  3. 当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。( 对应访问变量武值变量操作)

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      public class ActiveUse2 {
      @Test
      public void test1(){
      // System.out.println(User.num); // User类的初始化过程
      // System.out.println(User.num1); // 1
      System.out.println(User.num2); // User类的初始化过程 8
      }
      @Test
      public void test2(){
      // System.out.println(CompareA.NUM1); // 1
      System.out.println(CompareA.NUM2); // CompareA的初始化 5
      }
      }

      class User{
      static{
      System.out.println("User类的初始化过程");
      }
      public static int num = 1; // 在初始化阶段<clinit>()中赋值
      public static final int num1 = 1; // 在链接阶段的准备环节赋值,不需要调用<clinit>()
      public static final int num2 = new Random().nextInt(10); // 在初始化阶段<clinit>()中赋值

      }

      interface CompareA{
      // 通过一个静态内部方法展示有没有JVM调用<clinit>()方法
      public static final Thread t = new Thread(){
      {
      System.out.println("CompareA的初始化");
      }
      };

      public static final int NUM1 = 1; // 在链接阶段的准备环节赋值,不需要调用<clinit>()
      public static final int NUM2 = new Random().nextInt(10); // 在初始化阶段<clinit>()中赋值

      }
  4. 当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forName(“com.atguigu.java.Test”);

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Test
      public void test1() {
      try {
      Class clazz = Class.forName("com.atguigu.java1.Order");
      } catch (ClassNotFoundException e) {
      e.printStackTrace();
      }
      }
      class Order implements Serializable{
      static {
      System.out.println("Order类的初始化过程");
      }
      }
  5. 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @Test
      public void test2() {
      // Father类的初始化过程
      // Son类的初始化过程
      // 1
      System.out.println(Son.num);
      }
      class Father {
      static {
      System.out.println("Father类的初始化过程");
      }
      }
      class Son extends Father implements CompareB{
      static {
      System.out.println("Son类的初始化过程");
      }
      public static int num = 1;
      }
    • 其实在加载Father类之前,JVM还会加载Father的父类java.lang.Object。但是这里不好展示。可以通过JVM参数-XX:+TraceClassLoading可以追踪类的加载信息并打印出来。在当中可以看到JVM有先加载java.lang.Object。

    • 关于采用Junit进行测试时JVM参数的设置:

      img

  6. 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      @Test
      public void test4() {
      // Father类的初始化过程
      // CompareB的初始化
      // Son类的初始化过程
      // 1
      System.out.println(Son.num);
      }
      class Father {
      static {
      System.out.println("Father类的初始化过程");
      }
      }
      class Son extends Father implements CompareB{
      static {
      System.out.println("Son类的初始化过程");
      }
      public static int num = 1;
      }
      interface CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareB的初始化");
      }
      public default void method1(){
      System.out.println("你好!");
      }
      };
      }
    • 如果Son还被其他类继承的话,当Son的子类初始化的时候,对应的Son,CompareB,Father也会被初始化。

  7. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public class ActiveUse3 {
      static{
      System.out.println("ActiveUse3的初始化过程");
      }
      public static void main(String[] args) {
      // ActiveUse3的初始化过程
      // hello
      System.out.println("hello");
      }
      }
  8. 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类。( 涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类)

针对5,补充说明:

当Java虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。

  • 在初始化一个类时,并不会先初始化它所实现的接口

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      @Test
      public void test2() {
      // Father类的初始化过程
      // Son类的初始化过程
      // 1
      System.out.println(Son.num);
      }
      class Father {
      static {
      System.out.println("Father类的初始化过程");
      }
      }
      class Son extends Father implements CompareB{
      static {
      System.out.println("Son类的初始化过程");
      }
      public static int num = 1;
      }
      interface CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareB的初始化");
      }
      };
      }
    • 虽然接口CompareB没有初始化,但是它已经被加载进JVM当中了

  • 在初始化一个接口时,并不会先初始化它的父接口

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      @Test
      public void test3(){
      // CompareC的初始化
      // 3
      System.out.println(CompareC.NUM1);
      }
      interface CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareB的初始化");
      }
      };
      }
      interface CompareC extends CompareB {
      public static final Thread t = new Thread() {
      {
      System.out.println("CompareC的初始化");
      }
      };
      public static final int NUM1 = new Random().nextInt();
      }

因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化。

针对7,说明:

JVM启动的时候通过引导类加载器加载一个初始类。这个类在调用public static void main(String[])方法之前被链接和初始化。这个方法的执行将依次导致所需的类的加载,链接和初始化。

2、被动使用

除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化

也就是说:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化

  1. 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。

    • 当通过子类引用父类的静态变量,不会导致子类初始化

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      @Test
      public void test1(){
      System.out.println(Child.num);
      }
      class Parent{
      static{
      System.out.println("Parent的初始化过程");
      }

      public static int num = 1;
      }
      class Child extends Parent{
      static{
      System.out.println("Child的初始化过程");
      }
      }
  2. 通过数组定义类引用,不会触发此类的初始化

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Test
      public void test2(){
      // [空]
      Parent[] parents = new Parent[10];
      // class [Lcom.atguigu.java1.Parent;
      System.out.println(parents.getClass());
      // class java.lang.Object
      System.out.println(parents.getClass().getSuperclass());
      // Parent的初始化过程
      parents[0] = new Parent();
      parents[1] = new Parent();
      }
      class Parent{
      static{
      System.out.println("Parent的初始化过程");
      }
      }
    • 只是定义不赋值的话,不会触发类初始化。但是只要赋上一次值就会执行类的初始化,之后就不会执行类的初始化了。(()这样一个类构造器方法只会初始化一次)

  3. 引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      @Test
      public void test1(){
      // System.out.println(Person.NUM); // 1
      // SerialA的初始化
      // 3
      System.out.println(Person.NUM1);
      }
      @Test
      public void test2(){
      // System.out.println(SerialA.ID); // 1
      // Person类的初始化
      // 3
      System.out.println(SerialA.ID1);
      }
      class Person{
      static{
      System.out.println("Person类的初始化");
      }
      public static final int NUM = 1;//在链接过程的准备环节就被赋值为1了。
      public static final int NUM1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
      }
      interface SerialA{
      public static final Thread t = new Thread() {
      {
      System.out.println("SerialA的初始化");
      }
      };
      int ID = 1;
      int ID1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
      }
  4. 调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

    • 举例:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      @Test
      public void test3(){
      try {
      Class clazz = ClassLoader.getSystemClassLoader().loadClass("com.atguigu.java1.Person");
      } catch (ClassNotFoundException e) {
      e.printStackTrace();
      }
      }
      class Person{
      static{
      System.out.println("Person类的初始化");
      }
      public static final int NUM = 1;//在链接过程的准备环节就被赋值为1了。
      public static final int NUM1 = new Random().nextInt(10);//此时的赋值操作需要在<clinit>()中执行
      }
3、注意
  • ClassLoader.getSystemClassLoader().loadClass()方法与Class.forName()方法
    • Class.forName()方法:类自动使用
    • ClassLoader.getSystemClassLoader().loadClass()方法:类被动使用

5、过程四:类的Using(使用)

任何一个类型在使用之前都必须经历过完整的加载、链接和初始化3个类加载步骤。一旦一个类型成功经历过这3个步骤之后,就应经加载成功了。便“万事俱备,只欠东风”,就等着开发者使用了。

开发人员可以在程序中访问和调用它的静态类成员信息(比如:静态字段、静态方法),或者使用new关键字为其创建对象实例。

6、过程五:类的Unloading(卸载)

1、类、类的加载器、类的实例之间的引用关系

在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另一方面,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法, 就能获得它的类加载器。由此可见,代表某个类的Class实例与其类的加载器之间为双向关联关系

一个类的实例总是引用代表这个类的Class对象。在Object类中定义了getClass()方法, 这个方法返回代表对象所属类的Class对象的引用。

此外,所有的Java类都有一个静态属性class,它引用代表这个类的Class对象

2、类的生命周期

当Sample类被加载、链接和初始化后,它的生命周期就开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。

一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期

3、具体例子

image-20210523011825913

loader1变量和obj变量间接引用代表Sample类的Class对象,而objClass变量则直接引用它。

  • 关于方法区的垃圾回收(回顾):方法区的垃圾收集主要回收两部分内容:**常量池中废弃的常量不再使用的类型**。(可对应上面的图)
    • HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收
    • 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于 “不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
      • 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
      • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGiJSP的重加载等,否则通常是很难达成的。
      • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
    • Java虚拟机**被允许(不是必然)**对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

如果程序运行过程中,将上图左侧三个引用变量都置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载。

当再次有需要时,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载如果不存在Sample类会被重新加载,在Java 虚拟机的堆区会生成一个新的代表Sample类的Class实例(可以通过哈希码查看是否是同一个实例)。

4、类的卸载
  1. 启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范)
  2. 被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小。
  3. 被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到。可以预想,稍微复杂点的应用场景中(比如:很多时候用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的)。

综合以上三点,一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。同时我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下,来实现系统中的特定功能

7、相关大厂面试题

  • 蚂蚁金服:
    • 描述一下JVM加载Class文件的原理机制?
    • 一面:类加载过程
  • 百度:
    • 类加载的时机
    • java类加载过程?
    • 简述java类加载机制?
  • 腾讯:
    • JVM中类加载机制,类加载过程?
  • 滴滴:
    • JVM类加载机制
  • 美团:
    • Java类加载过程
    • 描述一下jvm加载class文件的原理机制
  • 京东:
    • 什么是类的加载?
    • 哪些情况会触发类的加载?
    • 讲一下JVM加载一个类的过程
    • JVM的类加载机制是什么?

4、再谈类的加载器

1、概述

类加载器是JVM执行类加载机制的前提

ClassLoader的作用:

ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader 负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由Execution Engine(执行引擎)决定

中篇_第4章:类的加载器

类加载器最早出现在Java1.0版本中,那个时候只是单纯地为了满足Java Applet应用而被研发出来。但如今类加载器却在OSGi、字节码加解密领域大放异彩。这主要归功于Java虚拟机的设计者们当初在设计类加载器的时候,并没有考虑将它绑定在JVM内部,这样做的好处就是能够更加灵活和动态地执行类加载操作

1、类加载器的分类

类的加载分类:**显式加载** vs 隐式加载

class文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式

  • 显式加载:指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClass().getClassLoader().loadClass(name)加载class对象。
  • 隐式加载:则是不直接在代码中调用ClassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class 文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中(例如User user = new User())。(常用)

日常开发以上两种方式一般会混合使用

2、类加载器的必要性

一般情况下,Java开发人员并不需要在程序中显式地使用类加载器,但是了解类加载器的加载机制却显得至关重要。从以下几个方面说:

  • 避免在开发中遇到java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时手足无措。只有了解类加载器的加载机制才能够在出现异常的时候快速地根据错误异常日志定位问题和解决问题。
  • 需要支持类的动态加载需要对编译后的字节码文件进行加解密操作时,就需要与类加载器打交道了。
  • 开发人员可以在程序中编写自定义类加载器来重新定义类的加载规则,以便实现一些自定义的处理逻辑。(自己定义的类加载器可以不遵从沙箱安全模型,因为沙箱安全模型有它的缺点)
3、命名空间
  1. 何为类的唯一性:
    对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。
  2. 命名空间:
    • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成
    • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
    • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类

在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

代码解释:

img

结果:

img

解释:

rootDir后面的地址是我们使用javac User.class指令生成的class文件地址,然后loader1和loader2是两个用户自定义类加载器(如果自定义的不必理解),之后使用这两个用户自定义类加载器加载同一类型的User类,获得的Class对象不是同一个,可以通过Class对象调用getClassLoader()方法获取对应的类加载器了,最后通过系统类加载器获取的Class对象也是独特的,也可以通过该Class对象获取系统类加载器

4、类加载机制的基本特征

通常类加载机制有三个基本特征:

  • 双亲委派模型
    • 但不是所有类加载都遵守这个模型,有的时候,启动类加载器所加载的类型,是可能要加载用户代码的,比如JDK内部的ServiceProvider/ServiceLoader机制,用户可以在标准API框架上,提供自己的实现,JDK也需要提供些默认的参考实现。
    • 例如,Java 中JNDI、JDBC、文件系统、Cipher 等很多方面,都是利用的这种机制,这种情况就不会用双亲委派模型去加载,而是利用所谓的上下文加载器。
    • 安全 避免重复加载 保护程序 防止核心api被串改
  • 可见性
    • 子类加载器可以访问父加载器加载的类型,但是反过来是不允许的。不然,因为缺少必要的隔离,我们就没有办法利用类加载器去实现容器的逻辑。
  • 单一性
    • 由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互相并不可见

2、复习:类的加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器(Bootstrap ClassLoader) 和自定义类加载器(User-Defined ClassLoader) 。

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况:

image-20210523150158667

  • 除了顶层的启动类加载器外,其余的类加载器都应当有自己的“父类”加载器。
  • 不同类加载器看似是继承(Inheritance)关系,实际上是包含关系。在下层加载器中,包含着上层加载器的引用

父类加载器和子类加载器的关系:

image-20210523150646271

正是由于子类加载器中包含着父类加载器的引用,所以可以通过子类加载器的方法获取对应的父类加载器

注意:

启动类加载器通过C/C++语言编写,而自定义类加载器都是由Java语言编写的,虽然扩展类加载器和应用程序类加载器是被jdk开发人员使用java语言来编写的,但是也是由java语言编写的,所以也被称为自定义类加载器。

1、启动类加载器(引导类加载器,Bootstrap ClassLoader)
  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部
  • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jarsun.boot.class.path路径下的内容)。用于提供JVM自身需要的类。
  • 并不继承自java.lang.ClassLoader,没有父加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载包名为javajavaxsun 等开头的类
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器。(聚合)

image-20210523152412581

image-20210523152438385

使用-XX: +TraceClassloading参数得到。

启动类加载器使用C++编写的? Yes!

  • C/C++E 指针函数&函数指针、C++支持多继承、更加高效
  • Java: 由C++演变而来, (C++)–版,单继承

引导类加载器需要加载的jar包文件:

代码:

img

结果:

img

2、扩展类加载器(Extension ClassLoader)
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。
  • 继承于ClassLoader类
  • 父类加载器为启动类加载器。(聚合)
  • java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

image-20210523152834151

image-20210523155350716

注意:扩展类加载器与系统类加载器是属于同一级的,都是继承与URLClassLoader,只是扩展类加载器当中有系统类加载器的引用,所以才称系统类加载器是扩展类加载器的”父类”加载器。(两者并不是继承关系,而是一种聚合关系

无法通过扩展类加载器获得引导类加载器,因为引导类加载器是用C/C++语言编写的,所以获取的值是null

扩展类加载器:

代码:

img

结果:

img

3、应用程序类加载器(系统类加载器,AppClassLoader)
  • java语言编写,由sun.misc.Launcher$AppClassLoader实现
  • 继承于ClassLoader类
  • 父类加载器为扩展类加载器。(聚合)
  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 应用程序中的类加载器默认是系统类加载器
  • 它是用户自定义类加载器的默认父加载器
  • 通过ClassLoader的getSystemClassLoader( )方法可以获取到该类加载器

image-20210523155305052

4、用户自定义类加载器
  • 在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的。在必要时,我们还可以自定义类加载器,来定制类的加载方式。
  • 体现Java语言强大生命力和巨大魅力的关键因素之一便是:Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是本地的JAR包,也可以是网络上的远程资源
  • 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例举不胜举。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无须重新打包发布应用程序就能实现。
  • 同时,自定义加载器能够实现应用隔离,例如Tomcat,Spring等中间件和组件框架都在内部实现了自定义的加载器,并通过自定义加载器隔离不同的组件模块。这种机制比C/C++程序要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。
  • 自定义类加载器通常需要继承于ClassLoader.

3、测试不同的类的加载器

每个Class对象都会包含一个定义它的ClassLoader的一个引用

获取ClassLoader的途径

1
2
3
4
5
6
7
8
// 获得当前类的ClassLoader
clazz. getClassLoader();

// 获得当前线程上下文的ClassLoader
Thread.currentThread().getContextClassLoader();

// 获得系统的ClassLoader
ClassLoader.getSystemClassLoader();

说明:
站在程序的角度看,引导类加载器与另外两种类加载器(系统类加载器和扩展类加载器)并不是同一个层次意义上的加载器,引导类加载器是使用C++语言编写而成的,而另外两种类加载器则是使用Java语言编写而成的。由于引导类加载器压根儿就不是一个Java类,因此在Java程序中只能打印出空值

数组类的Class对象,不是由类加载器去创建的,而是在Java运行期JVM根据需要自动创建的。对于数组类的类加载器来说,是通过Class.getClassLoader( )返回的,与数组当中元素类型的类加载器是一样的;如果数组当中的元素类型是基本数据类型,数组类是没有类加载器的

代码:

1
2
3
4
5
6
7
8
9
//关于数组类型的加载:使用的类的加载器与数组元素的类的加载器相同
String[] arrStr = new String[10];
System.out.println(arrStr.getClass().getClassLoader());//null:表示使用的是引导类加载器

ClassLoaderTest1[] arr1 = new ClassLoaderTest1[10];
System.out.println(arr1.getClass().getClassLoader());//sun.misc.Launcher$AppClassLoader@18b4aac2

int[] arr2 = new int[10];
System.out.println(arr2.getClass().getClassLoader());//null:不需要类的加载器

**获取当前线程上下文的ClassLoader的结果就是系统类加载器**,这个可以在Launcher.java中被代码证明:

1
2
3
4
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
Thread.currentThread().setContextClassLoader(this.loader);
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
Thread.currentThread().setContextClassLoader(this.loader);

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ClassLoaderTest1 {
public static void main(String[] args) {
//获取系统该类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
//获取扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
//试图获取引导类加载器:失败
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null
//###########################
try {
ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader();
System.out.println(classLoader);
//自定义的类默认使用系统类加载器
ClassLoader classLoader1 = Class.forName("com.atguigu.java.ClassLoaderTest1").getClassLoader();
System.out.println(classLoader1);
//关于数组类型的加载:使用的类的加载器与数组元素的类的加载器相同
String[] arrStr = new String[10];
System.out.println(arrStr.getClass().getClassLoader());//null:表示使用的是引导类加载器

ClassLoaderTest1[] arr1 = new ClassLoaderTest1[10];
System.out.println(arr1.getClass().getClassLoader());//sun.misc.Launcher$AppClassLoader@18b4aac2
// 关于数组当中的元素类型是基本数据类型
int[] arr2 = new int[10];
System.out.println(arr2.getClass().getClassLoader());//null:不需要类的加载器

System.out.println(Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}

结果:

img

4、ClassLoader源码解析

ClassLoader与现有类加载器的关系:

image-20210523193421376

除了以上虚拟机自带的加载器外,用户还可以定制自己的类加载器。Java提供了抽象类java.lang.ClassLoader, 所有用户自定义的类加载器都应该继承ClassLoader类。

1、类加载器之间的关系

Launcher.class:

img

ExtClassLoader和AppClassLoader是Launcher类的两个内部类:

img

img

分析:

  1. 验证扩展类加载器的父类是null
    • 先看:var1 = Launcher.ExtClassLoader.getExtClassLoader();
    • 获取到扩展类加载器,点击该方法往里面追溯,在最后找到:return new Launcher.ExtClassLoader(var0);
    • 我们在点击该方法往里面追溯,在找到:super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
    • 然后点击super,往里面追溯,在找到:public URLClassLoader(URL[] urls, ClassLoader parent,URLStreamHandlerFactory factory){super(parent)}
    • 点击其中的parent(也就是null值),我们点击super,往里面追溯,在找到:protected SecureClassLoader(ClassLoader parent) { super(parent);}
    • 点击其中的parent就是null,我们点击super,往里面追溯,在找到:protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent);}
    • 点击其中的parent就是null,我们点击this,往里面追溯,在找到:private ClassLoader(Void unused, ClassLoader parent) { this.parent = parent;}
    • 点击其中的parent就是null,可以看到是:private final ClassLoader parent;
      • 就是ClassLoader.java定义的属性:父类加载器
    • 由于parent就是null,所以扩展类加载器的父类是null,也就是引导类加载器,因此我们调用获取扩展类加载器父类的方法获得的结果是null
  2. 验证系统类加载器的父类是扩展类加载器
    • 先看:this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    • 获取到系统类加载器,点击该方法往里面追溯,在最后找到:return new Launcher.AppClassLoader(var1x, var0);
    • 其中var0就是扩展类加载器,点击AppClassLoader,往里面追溯,在找到:AppClassLoader(URL[] var1, ClassLoader var2) { super(var1, var2, Launcher.factory); this.ucp.initLookupCache(this);}
    • 其中var2就是扩展类加载器,我们点击super,往里面追溯,在找到:public URLClassLoader(URL[] urls, ClassLoader parent,URLStreamHandlerFactory factory) {super(parent);}
    • 里面的parent就是扩展类加载器,我们点击super,往里面追溯,在找到:protected SecureClassLoader(ClassLoader parent) { super(parent);}
    • 里面的parent就是扩展类加载器,我们点击super,往里面追溯,在找到:protected ClassLoader(ClassLoader parent) { this(checkCreateClassLoader(), parent);}
    • 里面的parent就是扩展类加载器,我们点击this,往里面追溯,在找到:private ClassLoader(Void unused, ClassLoader parent) { this.parent = parent;}
    • 点击其中的parent就是扩展类加载器,可以看到是:private final ClassLoader parent;
      • 就是ClassLoader.java定义的属性:父类加载器
    • 由于parent就是扩展类加载器,所以系统类加载器的父类是扩展类加载器,因此我们调用获取系统类加载器父类的方法获得的结果是扩展类加载器
  3. 当前线程上下文的ClassLoader就是系统类加载器
    • Thread.currentThread().setContextClassLoader(this.loader):就是将系统类加载器设置为当前线程的上下文加载器,所以Thread.currentThread().getContextClassLoader()获取到的就是系统类加载器

注意:

  • Launcher源码里定义了static的扩展类加载器ExtClassLoader, static的系统类加载器AppClassLoader。
  • 它们都是默认包级别的,它们都是继承URLClassLoader,这就意味着我们的代码里,不能定义ExtClassLoader laoder = …或AppClassLoader loader = …。我们只能ClassLoader loader = …,而在实际运行时,我们应当能辨别这个loader到底是哪个具体类型。
  • 在ExtClassLoader构造器里,并没有指定parent,或者说ExtClassLoader的parent为null。因为ExtClassLoader的parent是BootstrapLoader,而BootstrapLoader不存在于Java Api里,只存在于JVM里,我们是看不到的,所以请正确理解”ExtClassLoader的parent为null”的含义。
  • 在AppClassLoader构造器里,有了parent。实例化AppClassLoader的时候,传入的parent就是一个ExtClassLoader实例
  • 看看Launcher的构造方法:
    • **先实例化ExtClassLoader,从java.ext.dirs系统变量里获得URL[]**。
    • **用这个ExtClassLoader作为parent去实例化AppClassLoader,从java.class.path系统变量里获得URL[]**。Launcher getClassLoader()就是返回的这个AppClassLoader。
    • 设置AppClassLoader为ContextClassLoader
2、ClassLoader的主要方法

抽象类ClassLoader的主要方法:(内部没有抽象方法)

  • public final ClassLoader getParent():

    • 返回该类加载器的超类加载器
  • public Class<?> loadClass(String name) throws ClassNotFoundException:

    • 加载名称为name的类,返回结果为java.lang.Class类的实例。如果找不到类,则返回ClassNotFoundException异常。该方法中的逻辑就是双亲委派模式的实现
  • protected Class<?> findClass(String name) throws ClassNotFoundException:

    • 查找二进制名称为name的类,返回结果为java.lang.Class类的实例。这是一个受保护的方法, JVM鼓励我们重写此方法,需要自定义加载器遵循双亲委托机制,该方法会在检查完父类加载器之后被loadClass()方法调用。
      • 在JDK1.2之前,在自定义类加载时,总会去继承ClassLoader类并重写loadClass方法,从而实现自定义的类加载类。
      • 但是在JDK1. 2之后已不再建议用户去覆盖loadClass()方法,而是建议把自定义的类加载逻辑写在findClass()方法中,从前面的分析可知,findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式
      • 需要注意的是ClassLoader类中并没有实现findClass()方法的具体代码逻辑,取而代之的是抛ClassNotFoundException异常,同时应该知道的是findClass 方法通常是和defineClass方法起使用的。一般情况下,在自定义类加载器时,会直接覆盖ClassLoader 的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象
  • protected final Class<?> defineClass(String name, byte[] b, int off, int len):

    • 根据给定的字节数组b转换为Class的实例,off和len参数表示实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。这是受保护的方法,只有在自定义ClassLoader子类中可以使用。

      • defineClass()方法是用来将byte字节流解析成JVM能够识别的Class对象(ClassLoader中已实现该方法逻辑),通过这个方法不仅能够通过class文件实例化class对象, 也可以通过其他方式实例化class对象, 如通过网络接收一个类的字节码,然后转换为byte 字节流创建对应的Class对象。

      • defineClass()方法通常与findClass()方法起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则, 取得要加载类的字节码后转换成流, 然后调用defineClass()方法生成类的Class对象。

      • 简单举例:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        protected Class<?> findClass(String name) throws ClassNotFoundException {
        //获取类的字节数组
        byte[] classData = getClassData(name);
        if (classData == null) {
        throw new ClassNotFoundException();
        } else {
        //使用defineClass生成class对象
        return defineClass(name, classData, 0, classData. length);
        }
        }
  • protected final void resolveClass(Class<?> c):

    • 链接指定的一个Java类。使用该方法可以使用类的Class对象创建完成的同时也被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。
  • protected final Class<?> findLoadedClass(String name):

    • 查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例。这个方法是final方法,无法被修改
  • private final ClassLoader parent;

    • 它也是一个ClassLoader的实例,这个字段所表示的ClassLoader也称为这个ClassLoader的双亲在类加载的过程中, ClassLoader可能会将某些请求交予自己的双亲处理
关于loadClass()方法的剖析:

loadClass()方法是ClassLoader.java类中的主要方法

测试代码:ClassLoader.getSystemClassLoader().loadClass(“com.atguig.java.User”);

涉及到对如下方法的调用:(模板方法模式的实现,抽象类提供基本的方法框架,子类需要重写具体的方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
protected Class<?> loadClass(String name, boolean resolve) //resolve: true-加载class的同时进行解析操作。默认为false
throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) { //同步操作,保证只能加载一次。
// First, check if the class has already been Loaded
//首先,在缓在中判断是否已经加载同名的类。
Class<?> c = findLoadedClass(name);
if (c == nu1l) {
long t0 = System.nanoTime();
try {
// 获取当前类加载器的父类加载器
if (parent != nu1l) {
// 如果存在父类加载器,则调用父类加载器进行类的加载(递归)(双亲委派机制)
c = parent.loadClass(name, false);
} else { // parent为null:父类加载器是引导类加载器
c = findBootstrapClassOrNull(name);
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent cLass Loader
}
}
if (c == nu11) { // 当前类的加载器的父类加载器未加载此类or当前类的加载器未加载此类
// If still not found, then invoke findClass in order
// to find the class.
// 调用当前ClassLoader的findCLass()
long t1 = System.nanoTime();
C =findClass(name);
// this is the defining class Loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindCLassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {//是否进行解析操作
resolveClass(c);
}
return c;
}
}

分析:

  1. findLoadedClass(name),查找类是否已经被加载过,如果加载过直接返回该Class类型的对象。如果没有被加载则继续第三的操作!

  2. c = findBootstrapClassOrNull(name);c = parent.loadClass(name, false);如果父加载器不为空,那么调用父加载器的loadClass方法加载类,如果父加载器为空,那么调用虚拟机的加载器来加载类。(此过程当中是通过递归的方法改变c的类型为父类加载器的类型,体现了双亲委派机制)

    如果以上两个步骤都没有成功的加载到类,进入第三;

  3. c = findClass(name);使用自定义的findClass(name)方法来加载类。(递归回最初的那一层)

    这个时候,我们已经得到了加载之后的类,那么就根据resolve的值决定是否调用resolveClass方法。进入第五!

  4. resolveClass(c); 链接指定的类。这个方法给Classloader用来链接一个类,如果这个类已经被链接过了,那么这个方法只做一个简单的返回。否则,这个类将被按照 Java™规范中的Execution描述进行链接……

  5. 其中使用到了设计模式的模板方法模式

    • 模板方法模式用于定义构建某个对象的步骤与顺序,或者定义一个算法的骨架
    • 模板方法模式的使用的方式,给子类足够的自由度,提供一些方法供子类覆盖,去实现一些骨架中不是必须但却可以有自定义实现的步骤。模板方法模式是一种基础继承的代码复用技术。如ClassLoader中的findClass方法!
  6. 总结一下:在ClassLoader中定义的算法顺序是

    1. 首先看是否有已经加载好的类
    2. 如果父类加载器不为空,则首先从父类类加载器加载
    3. 如果父类加载器为空,则尝试从启动加载器加载
    4. 如果两者都失败,才尝试从findClass方法加载
3、SecureClassLoader与URLClassLoader
  • SecureClassLoader:
    • 接着SecureClassLoader扩展了ClassLoader, 新增了几个与使用相关的代码源(对代码源的位置及其证书的验证)和权限定义类验证(主要指对class源码的访问权限)的方法,一般我们不会直接跟这个类打交道,更多是与它的子类URLClassLoader有所关联。
  • URLClassLoader:
    • 前面说过,ClassLoader是一个抽象类, 很多方法是空的没有实现,比如findClass()、findResource()等(模板方法模式)。而URLClassLoader这个实现类为这些方法提供了具体的实现。并新增了URLClassPath类协助取得Class字节码流等功能,在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类, 这样就可以避免自己去编写findClass()方法及其获取字节码流的方式,使自定义类加载器编写更加简洁

image-20210523221840016

4、ExtClassLoader与AppClassLoader

了解完URLClassLoader后接着看看剩余的两个类加载器,即拓展类加载器ExtClassLoader系统类加载器AppClassLoader, 这两个类都继承自URLClassLoader, 是sun.misc.Launcher的静态内部类

sun.misc.Launcher主要被系统用于启动主应用程序,ExtClassLoader 和AppClassLoader都是由sun.misc.Launcher创建的,其类主要类结构如下:

img

我们发现ExtClassLoader并没有重写loadClass()方法,这足矣说明其遵循双亲委派模式,而AppClassLoader重载了loadClass()方法,但最终调用的还是父类loadClass()方法,因此依然遵守双亲委派模式

5、Class.forName()与ClassLoader.loadClass()
  • Class.forName():是一个静态方法,最常用的是Class.forName(String className);根据传入的类的全限定名返回一个Class对象。该方法在将Class文件加载到内存的同时,会执行类的初始化。如:Class.forName(“com. atguigu. java.HelloWorld”);
  • ClassLoader.loadClass():这是一个实例方法,需要一个ClassLoader 对象来调用该方法。该方法将Class 文件加载到内存时,并不会执行类的初始化,直到这个类第一次使用时才进行初始化(loadClass()方法当中的resolve: true-加载class的同时进行解析操作。默认为false)。该方法因为需要得到一个ClassLoader 对象,所以可以根据需要指定使用哪个类加载器。如:ClassLoader c1=…….;c1. loadClass(“com. atguigu. java . HelloWorld”);

5、双亲委派模型

1、定义与本质

类加载器用来把类加载到Java虚拟机中。从JDK1. 2版本开始,类的加载过程采用双亲委派机制,这种机制能更好地保证Java平台的安全

  1. 定义:

    如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载

  2. 本质:

    规定了类加载的顺序是:引导类加载器先加载,若加载不到,由扩展类加载器加载,若还加载不到,才会由系统类加载器或自定义的类加载器进行加载

    image-20210421103744316

image-20210524135243252

2、优势与劣势
1、双亲委派机制优势
  • 避免类的重复加载,确保一个类的全局唯一性
    • Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要于ClassLoader再加载一次。
  • 保护程序安全,防止核心API被随意篡改
2、代码支持

双亲委派机制在java.lang.ClassLoader.loadClass(String, boolean)接口中体现。该接口的逻辑如下:

  1. 先在当前加载器的缓存中查找有无目标类,如果有,直接返回。
  2. 判断当前加载器的父加载器是否为空,如果不为空,则调用parent.loadClass(name, false)接口进行加载。
  3. 反之,如果当前加载器的父类加载器为空,则调用findBootstrapClassOrNull(name)接口, 让引导类加载器进行加载
  4. 如果通过以上3条路径都没能成功加载,则调用findClass(name)接口进行加载。 该接口最终会调用java.lang.ClassLoader接口的defineClass系列的native接口加载目标Java类。

双亲委派的模型就隐藏在这第2和第3步的递归调用当中

3、举例:

假设当前加载的是java.lang.Object这个类,很显然,该类属于JDK中核心得不能再核心的一个类,因此一定只能由引导类加载器进行加载。当JVM准备加载java.lang.Object时,JVM默认会使用系统类加载器去加载,按照上面4步加载的逻辑,在第1步从系统类的缓存中肯定查找不到该类,于是进入第2步。由于从系统类加载器的父加载器是扩展类加载器,于是扩展类加载器继续从第1步开始重复。由于扩展类加载器的缓存中也一定查找不到该类,因此进入第2步。扩展类的父加载器是null,因此系统调用findClass(String),最终通过引导类加载器进行加载。

4、思考:

如果在自定义的类加载器中重写java.lang.ClassLoader.loadClass(String)或java.lang.ClassLoader.loadClass(String, boolean)方法, 抹去其中的双亲委派机制,仅保留上面这4步中的第1步与第4步,那么是不是就能够加载核心类库了呢?

这也不行!因为JDK还为核心类库提供了一层保护机制。不管是自定义的类加载器,还是系统类加载器抑或扩展类加载器,最终都必须调用java.lang.ClassLoader .defineClass(String, byte[], int, int, ProtectionDomain)方法,而该方法会执行preDefineClass()接口,该接口中提供了对JDK核心类库的保护

5、双亲委派模型的弊端:

检查类是否加载的委托过程是单向的**,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类**。

通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题,但是系统类访问应用类就会出现问题。比如在系统类中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。

由双亲委派模型的优势可以看出:Java类随着它的类加载器一起具备了一种带有优先级的层次关系——双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API。但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,由于BootstrapClassloader是顶级类加载器,BootstrapClassloader无法委派AppClassLoader来加载类,也就是说BootstrapClassloader中加载的类中无法使用由AppClassLoader加载的类。

举例:(java.sql与驱动接口:com.mysql.jdbc.Driver)

接口:java.sql.Driver,定义在java.sql包中,包所在的位置是:jdk\jre\lib\rt.jar中,java.sql包中还提供了其它相应的类和接口比如管理驱动的类:DriverManager类,很明显java.sql包是由BootstrapClassloader加载器加载的;而接口的实现类com.mysql.jdbc.Driver是由第三方实现的类库,由AppClassLoader加载器进行加载的,我们的问题是DriverManager再获取链接的时候必然要加载到com.mysql.jdbc.Driver类,这就是由BootstrapClassloader加载的类使用了由AppClassLoader加载的类,很明显和双亲委托机制的原理相悖。

6、结论:

由于Java虛拟机规范并没有明确要求类加载器的加载机制一定要使用双亲委派模型,只是建议采用这种方式而已。比如在Tomcat中,类加载器所采用的加载机制就和传统的双亲委派模型有一定区别,当缺省的类加载器接收到一个类的加载任务时,首先会由它自行加载,当它加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Servlet规范推荐的一种做法

3、破坏双亲委派机制

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的类加载器实现方式。

在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java模块化出现为止,双亲委派模型主要出现过3次较大规模“被破坏”的情况。

1、破坏双亲委派机制1——兼容JDK1.2之前的版本

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK 1.2面世以前的 “远古” 时代。

由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK1.2之后的java.lang.ClassLoader中添加一个新的protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码。

上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,按照loadClass()方法的逻辑, 如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

以上简单来说就是jdk1.2之前还没引入双亲委派机制,所以jdk1.2之前就是破坏双亲委派机制的情况。

2、破坏双亲委派机制2——线程上下文类加载器(解决双亲委派机制的弊端:上层类加载器加载的类不能使用由下层类加载器加载的类)

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务,它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型了。但JNDI 存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface, SPI) 的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?(SPI:在Java平台中, 通常把核心类rt.jar中提供外部服务、可由应用层自行实现的接口称为SPI)

为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)**。这个类加载器可以通过java.lang. Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器**。

有了线程上下文类加载器,程序就可以做一些“舞弊”的事情了。JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、JDBC、JCE、JAXB和JBl等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了java.util.ServiceLoader类,**以META-INF/services中的配置信息,辅以责任链模式**,这才算是给SPI的加载提供了一种相对合理的解决方案。

image-20210524154710225

默认上下文加载器就是应用类加载器,这样以上下文加载器为中介,使得启动类加载器中的代码也可以访问应用类加载器中的类

简单来说就是线程上下文类加载器让启动类加载器和系统类加载器直接联系起来了,中间的扩展类加载器被省略了,所以这破坏了双亲委派机制,其中线程上下文类加载器就是系统类加载器,这个证明在之前的ClassLoader >>> 类加载器之间的关系中 有相关代码的解释。

3、破坏双亲委派机制3——用户对程序动态性的追求:代码热替换(Hot Swap)、模块热部署(Hot Deployment) 等

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。如:代码热替换(Hot Swap)、模块热部署(Hot Deployment) 等

IBM公司主导的JSR-291 (即OSGi R4.2)实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起 换掉以实现代码的热替换。在OSGi环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构

当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

  1. 将以java.*开头的类,委派给父类加载器加载
  2. 否则,将委派列表名单内的类,委派给父类加载器加载
  3. 否则,将Import列表中的类, 委派给Export这个类的Bundle的类加载器加载
  4. 否则,查找当前Bundle的ClassPath, 使用自己的类加载器加载
  5. 否则,查找是否在自己的Fragment Bundle中, 如果在,则委派给Fragment Bundle的类加载器加载
  6. 否则,查找Dynamic Import列表的Bundle, 委派给对应Bundle的类加载器加载
  7. 否则,类查找失败

说明:只有开头两点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的

小结:

这里,我们使用了“被破坏”这个词来形容上述不符合双亲委派模型原则的行为,但这里“被破坏”并不一定是带有贬义的。只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新

正如:OSGi中的类加载器的设计不符合传统的双亲委派的类加载器架构,且业界对其为了实现热部署而带来的额外的高复杂度还存在不少争议,但对这方面有了解的技术人员基本还是能达成一个共识,认为OSGi中对类加载器的运用是值得学习的,完全弄懂了OSGi的实现,就算是掌握了类加载器的精粹

4、(补充)破坏双亲委派机制4——JDK9引入了Java模块化系统(具体在下文JDK9的新特性详细说明)

JDK9引入了Java模块化系统(Java Platform Module System)来实现可配置的封装隔离机制,同时JVM对类加载的架构也做出了调整,也就是双亲委派模型的第四次破坏。

传统的双亲委派加机制:(图示)这里的敌人就是我们要加载的jar包

img

缺点

通过上面的漫画不言而喻,当真正的敌人来了,靠这种低效的传达机制,怎么可能打一场胜仗呢?

  • 启动类加载器负责加载\jre\lib目录
  • 扩展类加载器负责加载\jre\lib\ext目录
  • 应用类加载器负责加载ClassPath目录。

既然一切都是各司其职,为什么不能加载类的时候一步到位呢?

通过分析JDK9的类加载器源码,我发现最新的类加载器结构在一定程度上是缓解了这种情况的

JDK的模块化

在JDK9之前,JVM的基础类以前都是在rt.jar这个包里,这个包也是JRE运行的基石。这不仅是违反了单一职责原则,同样程序在编译的时候会将很多无用的类也一并打包,造成臃肿

在JDK9中,整个JDK都基于模块化进行构建,以前的rt.jar, tool.jar被拆分成数十个模块,编译的时候只编译实际用到的模块,同时各个类加载器各司其职,只加载自己负责的模块。

img

模块化加载源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Class<?> c = findLoadedClass(cn); 
if (c == null) {
// 找到当前类属于哪个模块
LoadedModule loadedModule = findLoadedModule(cn);
if (loadedModule != null) {
//获取当前模块的类加载器
BuiltinClassLoader loader = loadedModule.loader();
//进行类加载
c = findClassInModuleOrNull(loadedModule, cn);
} else {
// 找不到模块信息才会进行双亲委派
if (parent != null) {
c = parent.loadClassOrNull(cn);
}
}

上面代码就是破坏双亲委派模型的“铁证”,而当我们继续跟进findLoadedModule,会发现是根据路径名找到对应的模块,而维护这一数据结构的就是下面这个Map:

1
Map<String, LoadedModule> packageToModule = new ConcurrentHashMap<>(1024);

可以看到LoadedModule里面不仅有该模块的loader信息,还有用于描述依赖模块,对外暴露模块的信息的mref,LoadedModule也是模块化实现封装隔离机制的一块重要实现

img

每一个module信息都有一个BuiltinClassloader,这个类有三个子类,我们通过源码分析他们的父子关系:

img

在ClassLoaders类中可以发现,PlatformClassLoader的parent是BootClassLoader,而AppClassLoader的parent则是PlatformClassLoader

1
2
3
4
5
6
7
8
9
10
11
12
public class ClassLoaders {
// the built-in class loaders
private static final BootClassLoader BOOT_LOADER;
private static final PlatformClassLoader PLATFORM_LOADER;
private static final AppClassLoader APP_LOADER;
static {
BOOT_LOADER = new BootClassLoader((append != null && !append.isEmpty()) ? new URLClassPath(append, true) : null);
PLATFORM_LOADER = new PlatformClassLoader(BOOT_LOADER);
...;
APP_LOADER = new AppClassLoader(PLATFORM_LOADER, ucp);
}
}

结论

  1. 经过破坏后的双亲委派模型更加高效,减少了很多类加载器之间不必要的委派操作
  2. JDK9的模块化可以减少Java程序打包的体积,同时拥有更好的隔离线与封装性
  3. 每个module拥有专属的类加载器,程序在并发性上也会更加出色
4、热替换的实现

热替换是指在程序的运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。热替换的关键需求在于服务不能中断,修改必须立即表现正在运行的系统之中。基本上大部分脚本语言都是天生支持热替换的,比如:PHP, 只要替换了PHP源文件,这种改动就会立即生效,而无需重启Web服务器。

但对Java来说,热替换并非天生就支持,如果一个类已经加载到系统中,通过修改类文件,并无法让系统再来加载并重定义这个类。因此,在Java中实现这一功能的一个可行的方法就是灵活运用ClassLoader。

注意:由不同ClassLoader加载的同名类属于不同的类型,不能相互转换和兼容。即两个不同的ClassLoader加载同一个类,在虚拟机内部,会认为这两个类是完全不同的。根据这个特点,可以用来模拟热替换的实现,基本思路如下图所示:

中篇_第4章:热替换

每次调用方法之前都要加载字节码文件,然后创建对象,我们可以把字节码文件变成最新的,那么创建的对象肯定是最新的,所以这就完成了热替换

6、沙箱安全机制

沙箱安全机制:

  • 保证程序安全
  • 保护Java原生的JDK代码

**Java安全模型的核心就是Java沙箱(sandbox)**。什么是沙箱?

  • 沙箱是一个限制程序运行的环境

沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问。通过这样的措施来保证对代码的有限隔离,防止对本地系统造成破坏。

沙箱主要限制系统资源访问,那系统资源包括什么?

  • CPU
  • 内存
  • 文件系统
  • 网络

不同级别的沙箱对这些资源访问的限制也可以不一样。

所有的Java程序运行都可以指定沙箱,可以定制安全策略。

1、JDK1.0时期

在Java中将执行程序分成本地代码远程代码两种:

  • 本地代码默认视为可信任的,对于授信的本地代码,可以访问一切本地资源。
  • 远程代码则被看作是不受信的。 而对于非授信的远程代码在早期的Java实现中, 安全依赖于沙箱(Sandbox)机制。

如下图所示JDK1 .0安全模型:

image-20210524160050558

2、JDK1.1时期

JDK1.0中如此严格的安全机制也给程序的功能扩展带来障碍,比如当用户希望远程代码访问本地系统的文件时候,就无法实现。因此在后续的Java1.1版本中,针对安全机制做了改进,增加了安全策略允许用户指定代码对本地资源的访问权限

如下图所示JDK1.1安全模型:

image-20210524160203739

3、JDK1.2时期

在Java1.2版本中,再次改进了安全机制,增加了代码签名。不论本地代码或是远程代码,都会按照用户的安全策略设定由类加载器加载到虛拟机中权限不同的运行空间,来实现差异化的代码执行权限控制

如下图所示JDK1.2安全模型:

image-20210524160319450

4、JDK1.6时期

当前最新的安全机制实现,则引入了**域(Domain)**的概念。

虚拟机会把所有代码加载到不同的系统域和应用域

  • **系统域部分专门负责与关键资源进行交互**;
  • 各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问
  • 虚拟机中不同的受保护域(ProtectedDomain),对应不一样的权限(Permission)**。存在于不同域中的类文件就具有了当前域的全部权限**。

如下图所示,最新的安全模型(jdk1.6):

image-20210524160541465

7、自定义类的加载器

1、为什么要自定义类加载器?
  • 隔离加载类
    • 在某些框架内进行中间件与应用的模块隔离,把类加载到不同的环境
    • 比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包
    • 再比如:Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序
  • 修改类加载的方式
    • 类的加载模型并非强制,除Bootstrap外, 其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需进行动态加载
  • 扩展加载源
    • 比如从数据库、网络、甚至是电视机机顶盒进行加载
  • 防止源码泄漏
    • Java代码容易被编译和篡改,可以进行编译加密。那么类加载也需要自定义,还原加密的字节码
2、常见的场景:
  • 实现类似进程内隔离类加载器实际上用作不同的命名空间,以提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器加载,就可以互不干扰。这个方面的集大成者是Java EEOSGIJPMS等框架。
  • 应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或者是需要自己操纵字节码,动态修改或者生成类型
3、注意

在一般情况下,使用不同的类加载器去加载不同的功能模块,会提高应用程序的安全性。但是,如果涉及Java类型转换则加载器反而容易产生不美好的事情。在做Java类型转换时,只有两个类型都是由同一个加载器所加载,才能进行类型转换,否则转换时会发生异常。(两个不同的类加载器加载同一个class文件,得到的两个类,虽然表面上看上去是一样的,但是却是不一样的两个类。当在进行类型转换的时候会抛异常)。

4、自定义类加载器的实现方式

用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑。

1、实现方式:
  • Java提供了抽象类java.lang.ClassLoader,所有用户自定义的类加载器都应该继承ClassLoader类
  • 在自定义ClassLoader的子类时候,我们常见的会有两种做法:
    1. 方式一:重写loadClass()方法(JDK1.2以前)
    2. 方式二:重写findClass()方法 –>推荐(JDK1.2以后)
2、对比

这两种方法本质上差不多,毕竟loadClass()也会调用findClass(), 但是从逻辑上讲我们最好不要直接修改loadClass()的内部逻辑。建议的做法是只在findClass()里重写自定义类的加载方法,根据参数指定类的名字,返回对应的Class对象的引用。

  • loadClass()这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构。同时,也避免了自己重写loadClass()方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择。
  • 当编写好自定义类加载器后,便可以在程序中调用loadClass()方法来实现类加载操作
3、说明
  • 其父类加载器是系统类加载器
  • JVM中的所有类加载都会使用java.lang.ClassLoader.loadClass(String)接口(自定义类加载器并重写java.lang.ClassLoader.loadClass(String)接口的除外),连JDK的核心类库也不能例外。
  • 如果你不想重写findClass()当中流相关的代码,同时也没什么需要改动的地方。可以尝试不去继承ClassLoader抽象类,而去继承抽象类ClassLoader的子类URLClassLoader,里面帮我们重写了findClass()方法。
4、实现代码

自定义类加载器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class MyClassLoader extends ClassLoader {
// class文件存放的目录
private String byteCodePath;
// 构造方法
public MyClassLoader(String byteCodePath) {
this.byteCodePath = byteCodePath;
}
public MyClassLoader(ClassLoader parent, String byteCodePath) {
super(parent);
this.byteCodePath = byteCodePath;
}
// 自定义类加载器的重点:重写findClass()方法
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
BufferedInputStream bis = null;
ByteArrayOutputStream baos = null;
try {
//获取字节码文件的完整路径
String fileName = byteCodePath + className + ".class";
//获取一个输入流
bis = new BufferedInputStream(new FileInputStream(fileName));
//获取一个输出流
baos = new ByteArrayOutputStream();
//具体读入数据并写出的过程
int len;
byte[] data = new byte[1024];
while ((len = bis.read(data)) != -1) {
baos.write(data, 0, len);
}
//获取内存中的完整的字节数组的数据
byte[] byteCodes = baos.toByteArray();
//调用defineClass(),将字节数组的数据转换为Class的实例。
Class clazz = defineClass(null, byteCodes, 0, byteCodes.length);
return clazz;
} catch (IOException e) {
e.printStackTrace();
} finally { // 关闭流的操作
try {
if (baos != null)
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (bis != null)
bis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}

测试自定义类加载器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyClassLoaderTest {
public static void main(String[] args) {
// 将class文件放在了D盘下
MyClassLoader loader = new MyClassLoader("d:/");
try {
// 需要加载的class文件名:Demo1
Class clazz = loader.loadClass("Demo1");
// 加载此类的类的加载器为: com.atguigu.java2.MyClassLoader
System.out.println("加载此类的类的加载器为:" + clazz.getClassLoader().getClass().getName());
// 加载当前Demo1类的类的加载器的父类加载器为: sun.misc.Launcher$AppClassLoader(系统类加载器)
System.out.println("加载当前Demo1类的类的加载器的父类加载器为:" + clazz.getClassLoader().getParent().getClass().getName());
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}

注意:

  • 需要将要加载的字节码文件放在一个文件下,或者重新javac编译一下源文件
  • 不然的话JVM依旧会使用系统加载器去加载你的class文件(因为你的class文件在系统加载器的加载的目录下)

8、Java9新特性

为了保证兼容性,JDK 9没有从根本上改变三层类加载器架构和双亲委派模型,但为了模块化系统的顺利运行,仍然发生了一些值得被注意的变动。

  1. 扩展机制被移除扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(platform class loader)。可以通过ClassLoader的新方法getPlatformClassLoader()来获取。(JDK9之前只能获取到系统类加载器,再通过系统类获取扩展类加载器,现在是直接可以获取到平台类加载器)

    • JDK 9时基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留\lib\ext目录,此前使用这个目录或者java.ext.dirs 系统变量来扩展JDK功能的机制已经没有继续存在的价值了。
  2. 平台类加载器和应用程序类加载器都不再继承自java.net.URLClassLoader

    • 现在**启动类加载器、平台类加载器、应用程序类加载器全都继承于jdk.internal.loader.BuiltinClassLoader**。

      image-20210524172221156

    • 如果有程序直接依赖了这种继承关系,或者依赖了URLClassLoader 类的特定方法,那代码很可能会在JDK 9及更高版本的JDK中崩溃

  3. 在Java 9中,类加载器有了名称。该名称在构造方法中指定,可以通过getName()方法来获取

    • 平台类加载器的名称是platform
    • 应用类加载器的名称是app

    类加载器的名称在调试与类加载器相关的问题时会非常有用

  4. 启动类加载器现在是在jvm内部和java类库共同协作实现的类加载器(以前是C++实现),但为了与之前代码兼容在获取启动类加载器的场景中仍然会返回null,而不会得到BootClassLoader实例

  5. 类加载的委派关系也发生了变动:

    • 当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载
  6. 双亲委派模式示意图:

    img

  7. 相关代码:(环境:JDK9)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class ClassLoaderTest {
    public static void main(String[] args) {
    // jdk.internal.loader.ClassLoaders$AppClassLoader@726f3b58
    System.out.println(ClassLoaderTest.class.getClassLoader());
    // jdk.internal.loader.ClassLoaders$PlatformClassLoader@e73f9ac
    System.out.println(ClassLoaderTest.class.getClassLoader().getParent());
    // null
    System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getParent());

    //获取系统类加载器
    System.out.println(ClassLoader.getSystemClassLoader());
    //获取平台类加载器
    System.out.println(ClassLoader.getPlatformClassLoader());
    //获取类的加载器的名称 app
    System.out.println(ClassLoaderTest.class.getClassLoader().getName());
    }
    }
  8. 附加:在Java模块化系统明确规定了三个类加载器负责各自加载的模块:

    • 启动类加载器负责加载的模块:

      image-20210524173906468

    • 平台类加载器负责加载的模块:

      image-20210524174130765

    • 应用程序类加载器负责加载的模块:

      image-20210524174026119

9、大厂面试题

  • 蚂蚁金服:
    • 深入分析ClassLoader,双亲委派机制
    • 类加载器的双亲委派模型是什么?
    • 一面:双亲委派机制及使用原因
  • 百度:
    • 都有哪些类加载器,这些类加载器都加载哪些文件?
    • 手写一个类加载器Demo
    • Class的forName(“java.lang.String”)和Class的getClassLoader()的loadClass(“java.lang.String”)有什么区别?
  • 腾讯:
    • 什么是双亲委派模型?
    • 类加载器有哪些?
  • 小米:
    • 双亲委派模型介绍一下
  • 滴滴:
    • 简单说说你了解的类加载器
    • 一面:讲一下双亲委派模型,以及其优点
  • 字节跳动:
    • 什么是类加载器,类加载器有哪些?
  • 京东:
    • 类加载器的双亲委派模型是什么?
    • 双亲委派机制可以打破吗?为什么

下篇:性能监控与调优篇

1、概述篇

1、背景说明

  1. 生产环境中的问题
    • 生产环境发生了内存溢出该如何处理
    • 生产环境应该给服务器分配多少内存合适?
    • 如何对垃圾回收器的性能进行调优?
    • 生产环境CPU负载飙高该如何处理?
    • 生产环境应该给应用分配多少线程合适?
    • 不加log,如何确定请求是否执行了某一行代码?
    • 不加log,如何实时查看某个方法的入参与返回值?
  2. 为什么要调优
    • 防止出现OOM
    • 解决OOM
    • 减少Full GC出现的频率
  3. 不同阶段的考虑
    • 上线前
    • 项目运行阶段
    • 线上出现OOM

2、调优概述

  1. 监控的依据
    • 运行日志
    • 异常堆栈
    • GC日志
    • 线程快照
    • 堆转储快照
  2. 调优的大方向
    • 合理地编写代码
    • 充分并合理的使用硬件资源
    • 合理地进行JVM调优

3、性能优化的步骤

1、第1步(发现问题):性能监控

一种以非强行或者入侵方式收集或查看应用运营性能数据的活动。
监控通常是指一种在生产、 质量评估或者开发环境下实施的带有预防或主动性的活动。
当应用相关干系人提出性能问题却没有提供足够多的线索时,首先我们需要进行性能监控,随后是性能分析。

主要的问题有:

  • GC频繁
  • cpu load过高
  • OOM
  • 内存泄露
  • 死锁
  • 程序响应时间较长
2、第2步(排查问题):性能分析

一种以侵入方式收集运行性能数据的活动,它会影响应用的吞吐量或响应性。
性能分析是针对性能问题的答复结果,关注的范围通常比性能监控更加集中。
性能分析很少在生产环境下进行,通常是在质量评估、系统测试或者开发环境下进行,是性能监控之后的步骤。

主要的手段:

  • 打印GC日志,通过GCviewer或者http://gceasy.io来分析异常信息
  • 灵活运用命令行工具、jstack、jmap、jinfo等
  • dump出堆文件,使用内存分析工具分析文件
  • 使用阿里Arthas、jconsole、JVisualVM来实时查看JVM状态
  • jstack查看堆栈信息
3、第3步(解决问题):性能调优

一种为改善应用响应性或香吐量而更改参数、源代码、属性配置的活动,性能调优
是在性能监控、性能分析之后的活动。

4、性能评价/测试指标

  1. 停顿时间(或响应时间)

    提交请求和返回该请求的响应之间使用的时间,一般比较关注平均响应时间常用操作的响应时间列表:

    image-20210712014649029

    在垃圾回收环节中:暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间(STW)

    • XX: MaxGCPauseMillis
  2. 吞吐量

    对单位时间内完成的工作量(请求)的量度

    在GC中:运行用户代码的事件占总运行时间的比例(总运行时间:程序的运行时间 + 内存回收的时间)

    吞吐量为1-1/(1+n),其中-XX::GCTimeRatio=n(这个参数只有在G1才能设置)

  3. 并发数

    同一时刻,对服务器有实际交互的请求数

    大概的标准:1000个人同时在线,估计并发数在5% - 15%之间,也就是同时并发量: 50 - 150之间。

  4. 内存占用

    Java堆区所占的内存大小

  • 主要的指标有:响应时间和吞吐量
  • 对于一个web应用关注的是:响应时间、吞吐量和并发数
  • 对于GC的时候在意的数据

响应时间、吞吐量和并发数相互间的关系(以高速公路通行状况为例):

  • 吞吐量:每天通过高速公路收费站的车辆的数据(也可以理解为收费站收取的高速费)
  • 并发数:高速公路上正在行驶的车辆的数目
  • 响应时间:车速

一开始的时候,高速公路上的车辆较少,车速较快,高速公路收费站收取的高速费较少——并发数少,响应时间快,吞吐量低

接着高速公路的车辆越来越多,车速变慢,高速公路收费站收取的高速费提高——并发数变多,响应时间变慢,吞吐量升高

随着高速公路的车辆越来越多,车速越来越慢,高速公路收费站收取的高速费降低——并发数越来越多,响应时间越来越慢,吞吐量降低

当高速公路的车辆发生事故,车速为0,高速公路收费站收取的高速费为0——并发数到顶,响应时间为0,吞吐量为0

2、JVM监控及诊断工具——命令行篇

1、概述

性能诊断是软件工程师在日常工作中需要经常面对和解决的问题,在用户体验至上的今天,解决好应用的性能问题能带来非常大的收益。

Java作为最流行的编程语言之一,其应用性能诊断一直受到业界广泛关注。可能造成Java应用出现性能问题的因素非常多,例如线程控制磁盘读写数据库访问网络I/O垃圾收集等。想要定位这些问题,一款优秀的性能诊断工具必不可少。

  • 体会1:使用数据说明问题,使用知识分析问题,使用工具处理问题
  • 体会2:无监控、不调优!

简单命令行工具:

在我们刚接触java学习的时候,大家肯定最先了解的两个命令就是javac、java,那么除此之外,还有没有其他的命令可以供我们使用呢?

我们进入到安装jdk的bin目录,发现还有一系列辅助工具。这些辅助工具用来获取目标JVM不同方面、不同层次的信息,帮助开发人员很好地解决Java应用程序的一些疑难杂症。这些辅助工具都是一个.exe的可执行文件的方式,若想要找到他的来源:jdk > jdk1.8.0_131 > lib > tool.jar包当中(都是一些.class文件)。相关源码:一般不需要我们去查看源码,除非你有特殊需求,需要自己书写修改源码。

mac系统:

image-20210712021836021

windows系统:

image-20210712021909775

2、jps:查看正在运行的Java进程

1、基本介绍

jps(Java Process Status):显示指定系统内所有的HotSpot虚拟机进程(查看虚拟机进程信息),可用于查询正在运行的虚拟机进程。

说明:

  • 对于本地虛拟机进程来说,进程的本地虚拟机ID与操作系统的进程ID是一致的,是唯一的
  • jps只对于在java HotSpot虚拟机运行的进程。
2、基本语法

它的基本使用语法为:jps [options] [hostid]

我们还可以通过追加参数,来打印额外的信息。可以通过 jps -help 来查看对应的参数信息

  • options参数

    • -q仅仅显示LVMID (local virtual machine id), 即本地虚拟机唯一id。不显示主类的名称等

      image-20210712024008753

    • -l:输出应用程序主类的全类名或如果进程执行的是jar包,则输出jar完整路径

      image-20210712024035710

    • -m:输出虚拟机进程启动时传递给主类main()的参数

      image-20210712024528755

      image-20210712024311552

      image-20210712024430556

    • -v:列出虚拟机进程启动时的JVM参数

      • 比如:- Xms20m - Xmx50m是启动程序指定的jvm参数。

      image-20210712024613610

      image-20210712024715127

    • 说明:以上参数可以综合使用:

      • -q指令单独使用(效果与其他三个相反),-lmv可以一起使用
      • jps -l -m 等价于 jps -lm
      • jps -l -m -v 等价于 jps -lmv
      • 如果-q与其他三个指令综合使用的话:(以-l为例,其他两个类似)
        • jps -q -l 等价于 jps -q(所以没什么必要,直接执行jps -q就行)
        • jps -ql:指令错误
      • 如何将信息输出到同级文件中:
        • 语法:命令 > 文件名称
        • 例如:jps -l > a.txt
    • 补充:

      • 如果某Java进程关闭了默认开启的UsePerfData参数(即使用参数-XX: -UsePerfData),那么jps命令(以及下面介绍的jstat)将无法探知该
        Java进程。

        image-20210712025059507

        image-20210712025219254

  • hostid参数

    • RMI注册表中注册的主机名。如果想要远程监控主机上的java 程序,需要安装jstatd。
    • 对于具有更严格的安全实践的网络场所而言,可能使用一个自定义的策略文件来显示对特定的可信主机或网络的访问,尽管这种技术容易受到IP地址欺诈攻击
    • 如果安全问题无法使用一个定制的策略文件来处理,那么最安全的操作是不运行jstatd服务器,而是在本地使用jstat和jps工具。
3、相关测试

image-20210712022534826

  • 10292:后面为空,查看任务管理器得知10292为操作系统为IDEA分配的进程ID,说明jps中后面为空的进程ID代表的就是IDEA进程(说明IDEA也是由java编写,运行在JVM虚拟机当中)
  • 6328 ScannerTest:自己写的测试程序
  • 14604 Jps:Jps本身的进程ID(说明在使用Jps的时候,Jps本身会创建一个进程)
  • 2732 Launcher:JVM虚拟机的进程ID

3、jstat:查看JVM统计信息

1、基本介绍

官方文档

jstat(JVM Statistics Monitoring Tool):用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、
JIT编译等运行数据

在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。常用于检测垃圾回收问题以及内存泄漏问题

2、基本语法

它的基本使用语法为:jstat -

其中vmid是进程id号,也就是jps之后看到的前面的号码,如下:

img

查看命令相关参数:jstat -hjstat -help

  • option参数

    • 类装载相关的:

      • -class显示ClassLoader的相关信息类的装载卸载数量总空间类装载所消耗的时间

        image-20210712225650046

        9000为进程ID(

    • 垃圾回收相关的:

      • -gc显示与GC相关的堆信息。包括Eden区两个Survivor区老年代永久代等的容量已用空间GC时间合计等信息。

        其中设置了JVM参数:-Xms60m -Xmx60m -XX:SurvivorRatio=8

        image-20210712231844604

        • C:总容量\次数、U:使用的容量、S1\0:Survivor0\1区、E:伊甸园区、O:老年代、M:方法区、CCS:压缩类、YG:young GC、FG:full GC、GC:GC、T:时间
        • 由于设置了-Xms60m -Xmx60m -XX:SurvivorRatio=8,即60M = 40M(Old) + 20M(young) 20M(young) = 16M(伊甸园区) + 2M(S0) + 2M(S1)
        • S0C:Survivor0区的容量:2M =2048
        • S1C:Survivor1区的容量:2M = 2048
        • S0U:Survivor0区使用的容量
        • S1U:Survivor1区使用的容量
        • EC:伊甸园区的容量:16M = 16384
        • EU:伊甸园区使用的容量
        • OC:老年代的容量:40M = 40960
        • OU:老年代使用的容量
        • MC:方法区的容量
        • MU:方法区使用的容量
        • CCSC:压缩类的容量
        • CCSU:压缩类使用的容量
        • YGC:young GC发生的次数
        • YGCT:发生young GC花费的时间
        • FGC:full GC发生的次数
        • FGCT:发生full GC花费的时间
        • GCT:用于GC的时间
      • -gccapacity显示内容与-gc基本相同,但输出主要关注Java堆各个区域使用到的最大、最小空间

      • -gcutil显示内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比

        image-20210712234001136

      • -gccause-gcutil功能一样,但是会额外输出导致最后一次或当前正在发生的GC产生的原因

        image-20210712234235632

      • -gcnew显示新生代GC状况

      • -gcnewcapacity显示内容与-gcnew基本相同,输出主要关注使用到的最大、最小空间

      • -geold显示老年代GC状况

      • -gcoldcapacity显示内容与-gcold基本相同,输出主要关注使用到的最大、最小空间

      • -gcpermcapacity:显示永久代使用到的最大、最小空间

    • JIT相关的:

      • -compiler显示JIT编译器编译过的方法、耗时等信息

        image-20210712231535247

      • -printcompilation输出已经被JIT编译的方法

        image-20210712231557164

  • interval参数:用于指定输出统计数据的周期,单位为毫秒。即:查询间隔。如果没加的话,默认查询1次,如果后面的count没有加的话,默认一直查询

    image-20210712230907909

  • count参数:用于指定查询的总次数。在加上interval参数的情况下,没加count参数,默认一直查询

    image-20210712230944650

    每1s显示一次ClassLoader的相关信息,显示10次。

  • -t参数:可以在输出信息前加上一个Timestamp列,显示程序的运行时间。单位:秒

    image-20210712231131085

    image-20210712231246385

    • 经验:

      • 我们可以比较Java进程的启动时间以及总GC时间(GCT列),或者两次测量的间隔时间以及总GC时间的增量,来得出GC时间占运行时间的比例。

      • 如果该比例超过20%,则说明目前堆的压力较大;如果该比例超过90%,则说明堆里几乎没有可用空间,随时都可能抛出OOM异常。

      • 我们执行jstat -gc -t 13152 1000 10,这代表1秒打印出1行,一共10行,-t代表打印出Timestamp总运行时间,结果如下所示:

        img

        上方红色框框中代表Timestamp,而蓝色框框中代表垃圾回收时间,单位都是秒,如果让红色框框中的某两个值相减,假设这个值是num1,然后让对应行的蓝色框框中的另外两个值相减,假设这个值是num2,之后让num2/num1,得出的差值就是上述所说的GC时间占运行时间的比例

  • h参数:可以在周期性数据输出时,输出多少行数据后输出一个表头信息

    image-20210712231441369

3、补充

jstat还可以用来判断是否出现内存泄漏。

  1. 第1步:在长时间运行的Java程序中,我们可以运行jstat命令连续获取多行性能数据,并取这几行数据中OU列(即己占用的老年代内存)的最小值
  2. 第2步:然后,我们每隔一段较长的时间重复一次上述操作,来获得多组OU最小值。如果这些值呈上涨趋势,则说明该Java程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏

4、jinfo:实时查看和修改JVM配置参数

1、基本介绍

官方文档

jinfo(Configuration Info for Java):查看虚拟机配置参数信息,也可用于调整虚拟机的配置参数

在很多情况下,Java应用程序不会指定所有的Java虚拟机参数。而此时,开发人员可能不知道某一个具体的Java虚拟机参数的默认值。在这种情况下,可能需要通过查找文档获取某个参数的默认值。这个查找过程可能是非常艰难的。但有了jinfo工具,开发人员可以很方便地找到Java虛拟机参数的当前值。

image-20210713000744298

2、基本语法

它的基本使用语法为:jinfo [ options ] pid

说明:java进程ID必须要加上。

[options] :

选项 选项说明
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+-]name 开启或关闭对应名称的参数
只有被标记为manageable的参数才可以被动态修改
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
  • 查看

    • jinfo -sysprops 进程id:可以查看由System.getProperties()取得的参数

      进程id可以通过jps命令查看,操作结果如下:(其中13152代表进程id)

      img

    • jinfo -flags 进程id:查看曾经赋过值的一些参数

      进程id可以通过jps命令查看,参数赋值的一部分是我们自己设置的,另外一部分是系统自动优化设置的参数信息,具体操作如下:(其中13152代表进程id)

      img

    • jinfo -flag 参数名称 进程id:查看某个java进程的具体参数信息

      进程id可以通过jps命令查看具体操作如下:(其中3540代表进程id)

      image-20210713001051530

  • 修改

    • jinfo不仅可以查看运行时某一个Java虚拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。但是,并非所有参数都支持动态修改。参数只有被标记为manageable的flag可以被实时修改其实,这个修改能力是极其有限的。

    • #可以查看被标记为manageable的参数:java -XX:+PrintFlagsFinal -version | grep manageable

      image-20210713001449847

    • 针对boolean类型

      • jinfo -flag [+|-]参数名称 进程id

        image-20210713001553125

    • 针对非boolean类型

      • jinfo -flag 参数名称=参数值 进程id

        image-20210713001642300

3、拓展
  • java -XX:+PrintFlagsInitial

    查看所有JVM参数启动的初始值

  • java -XX:+PrintFlagsFinal

    查看所有JVM参数的最终值

    image-20210713001929389

    值前面添加冒号:的是修改之后的值,没有添加的都是没有发生改变的初始值

  • java -参数名称:+PrintCommandLineFlags

    查看那些已经被用户或者JVM设置过的详细的XX参数的名称和值

5、jmap:导出内存映像文件&内存使用情况

1、基本介绍

官方帮助文档

jmap(JVM Memory Map):作用方面是获取dump文件 (堆转储快照文件,二进制文件),它还可以获取目标Java进程的内存相关信息,包括Java堆各区域的使用情况堆中对象的统计信息类加载信息等。

开发人员可以在控制台中输入命令“jmap -help” 查阅jmap工具的具体使用方式和一些标准选项配置。

image-20210713003354636

2、基本语法

它的基本使用语法为:

  • jmap [option]
  • jmap [option] <executable
  • jmap [option] [server_ id@]

其中的option包括:

选项 作用
-dump 生成dump文件
-finalizerinfo 显示在F-Queue中等待Finalizer线程执行finalize方法的对象
-heap 输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等等
-histo 输出堆空间中对象的统计信息,包括类、实例数量和合计容量
-permstat 以ClassLoader为统计口径输出永久代的内存状态信息
-F 当虚拟机对-dump选项没有任何响应的时候,强制执行生成dump文件
  1. 使用语法可以通过在DOS窗口中使用jmap/jmap -h/jmap -help查看jmap使用语法
  2. 文件名称代表可执行的代码,比如使用> 文件名称来指定生成的dump文件的生成位置
  3. [server_id@]是为远程连接准备的

指令描述:

  • **-dump**:生成Java堆转储快照:dump文件
    • 特别的:-dump:live只保存堆中的存活对象
  • **-heap**:输出整个堆空间的详细信息,包括GC的使用、堆配置信息,以及内存的使用信息等
  • **-histo**:输出堆中对象的同级信息,包括类、实例数量和合计容量
    • 特别的:-histo:live只统计堆中的存活对象
  • -permstat:以ClassLoader为统计口径输出永久代的内存状态信息
    • 仅linux/solaris平台有效
  • -finalizerinfo:显示在F-Queue中等待Finalizer线程执行finalize方法的对象
    • 仅linux/solaris平台有效
  • -F:当虚拟机进程对-dump选项没有任何响应时,可使用此选项强制执行生成dump文件
    • 仅linux/solaris平台有效
  • -h | -help:jamp工具使用的帮助命令
  • -J <flag>:传递参数给jmap启动的jvm
3、基本使用
1、使用1:导出内存映像文件

一般来说,使用jmap指令生成dump文件的操作算得上是最常用的jmap命令之一,将堆中所有存活对象导出至一个文件之中。

Heap Dump又叫做堆存储文件,指一个Java进程在某个时间点的内存快照。Heap Dump在触发内存快照的时候会保存此刻的信息如下:

  • All Objects
    Class,fields,primitive values and references
  • All Classes
    ClassLoader,name,super class,static fields
  • Garbage Collection Roots
    Objects defined to be reachable by the JVM
  • Thread Stacks and Local Variables
    The call-stacks of threads at the moment of the snapshot,and per-frame information about local objects

说明:

  1. 通常在写Heap Dump文件前会触发一次Full GC, 所以heap dump文件里保存的都是Full GC后留下的对象信息。
  2. 由于生成dump文件比较耗时,因此大家需要耐心等待,尤其是大内存镜像生成dump文件则需要耗费更长的时间来完成。

注意:

  1. 对于以上说明中的第1点是自动方式才会这样做,而手动不会在Full GC之后生成Dump
  2. 使用手动方式生成dump文件,一般指令执行之后就会生成,不用等到快出现OOM的时候
  3. 使用自动方式生成dump文件,当出现OOM之前先生成dump文件
  4. 如果使用手动方式,一般使用第2种,毕竟生成堆中存活对象的dump文件是比较小的,便于传输和分析

生成dump文件的方式:

  • 手动的方式

    • jmap -dump:format=b,file=<filename.hprof>
    • jmap -dump:live,format=b,file=<filename.hprof>
      • 小结的内容

    说明:

    • 其中file=后面的是生成的dump文件地址,最后的11696是进程id,可以通过jps查看
    • filename中的filename是文件名称,而.hprof是后缀名,代表该值可以省略
    • format=b表示生成的是标准的dump文件,用来进行格式限定
    • 一般使用的是第二种方式,也就是生成堆中存活对象的快照,毕竟这种方式生成的dump文件更小,我们传输处理都更方便

    具体例子如下:

    image-20210713010601073

  • 自动的方式

    当程序发生OOM退出系统时,一些瞬时信息都随着程序的终止而消失,而重现OOM问题往往比较困难或者耗时。此时若能在OOM时,自动导出dump文件就显得非常迫切。这里介绍一种比较常用的取得堆快照文件的方法,即使用:

    • -XX: +HeapDumpOnOutOfMemoryError在程序发生OOM时,导出应用程序的当前堆快照
    • -XX:HeapDumpPath:可以指定堆快照的保存位置
    • 比如:
      • -Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D: \m. hprof
    • 具体使用如下:
2、使用2:显示堆内存相关信息
  • jmap -heap 进程id

    • jmap -heap 进程id只是时间点上的堆信息,而jstat后面可以添加参数,可以指定时间动态观察数据改变情况,而图形化界面工具,例如jvisualvm等,它们可以用图表的方式动态展示出相关信息,更加直观明了。

    • 使用例子:

      image-20210713012001093

      image-20210713012319564

      image-20210713012602069

  • jmap -histo 进程id

    • 输出堆中对象的同级信息,包括类、实例数量和合计容量,也是这一时刻的内存中的对象信息

    • 使用例子:

      image-20210713012043421

      image-20210713012836602

3、使用3:其他作用
  • jmap -permstat 进程id
    • 查看系统的ClassLoader信息(永久代)
  • jmap -finalizerinfo
    • 查看堆积在finalizer队列中的对象

这两个指令仅linux/solaris平台有效,所以无法在windows操作平台上演示,并且使用比较小众,不在多说

4、小结

由于jmap将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,jmap需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。也就是说,由jmap导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差

举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么-dump:live选项将无法探知到这些对象。

另外,如果某个线程长时间无法跑到安全点,jmap将一直等下去。

与前面讲的jstat则不同,垃圾回收器会主动将jstat所需要的摘要数据保存至固定位置之中,而jstat只需直接读取即可

6、jhat:JDK自带堆分析工具

jhat命令在jdk9及其之后就被移除了,官方建议使用VisualVm代替jhat,所以该指令只需简单了解一下即可

1、基本介绍

jhat(JVM Heap Analysis Tool):Sun JDK提供的jhat命令与jmap命令搭配使用,**用于分析jmap生成的heap dump文件(堆转储快照)**。

jhat内置了一个微型的HTTP/HTML服务器(会CPU造成一定的压力),生成dump文件的分析结果后,用户可以在浏览器中查看分析结果(分析虚拟机转储快照信息)。

使用了jhat命令, 就启动了一个http服务,端口是7000, 即http://localhost:7000/,就可以在浏览器里分析。

说明:jhat 命令在JDK9、JDK10中已经被删除,官方建议用VisualVM代替。

image-20210713015031277

2、基本语法

它的基本使用语法为:jhat [option] [dumpfile]

其中dumpfile代表dump文件的地址以及名称,例如:jhat d:\1.hprof

option参数:

  • -stack false|true:关闭|打开对象分配调用栈跟踪
  • -refs false|true:关闭|打开对象引用跟踪
  • -port port-number:设置jhat HTTP Server的端口号,默认7000。例子:jhat -port 6565
  • -exclude exclude-file:执行对象查询时需要排除的数据成员
  • -debug int:设置debug级别
  • -version:启动后显示版本信息就退出
  • -J<flag>:传入启动参数,比如-J -Xmx512m

例子:

image-20210713020055637

image-20210713020318845

image-20210713020411853

注意:

  • 使用jhat一次只能分析一个.hprof文件,如果要分析另外一个.hprof文件的话,需要将之前打开的jhat关闭。

7、jstack:打印JVM中线程快照

1、基本介绍

官方帮助文档

jstack(JVM Stack Trace):用于生成虚拟机指定进程当前时刻的线程快照(虚拟机堆栈跟踪)。线程快照就是当前虚拟机内指定进程的每一条线程正在执行的方法堆栈的集合。

生成线程快照的作用:可用于定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等问题。这些都是导致线程长时间停顿的常见原因。当线程出现停顿时,就可以用jstack显示各个线程调用的堆栈情况

在thread dump中,要留意下面几种状态:

  • 死锁:Deadlock (重点关注)
  • 等待资源:Waiting on condition (重点关注)
  • 等待获取监视器:Waiting on monitor entry (重点关注)
  • 阻塞:Blocked (重点关注)
  • 执行中:Runnable
  • 暂停:Suspended
  • 对象等待中:Object.wait() 或 TIMED_WAITING
  • 停止:Parked
2、基本语法

image-20210713022747670

它的基本使用语法为:jstack option pid

jstack管理远程进程的话,需要在远程程序的启动参数中增加:

  • -Djava.rmi.server.hostname=……
  • -Dcom.sun.management.jmxremote
  • -Dcom.sun.management.jmxremote.port=8888
  • -Dcom.sun.management.jmxremote.authenticate=false
  • -Dcom.sun.management.jmxremote.ssl=false

总结:如果程序出现等待问题,可以使用该指令去查看问题所在,结果中也会提示你问题所在

option参数:

  • -F:当正常输出的请求不被响应时,强制输出线程堆栈
  • -l:除堆栈外,显示关于锁的附加信息
  • -m:如果调用本地方法的话,可以显示C/C++的堆栈
  • -h:帮助操作

在java层面实现jstack功能:

1
2
3
4
5
6
7
8
9
10
Map<Thread, StackTraceElement[]> all = Thread.getAllStackTraces(); //追踪当前进程中的所有的线程
Set<Map.Entry<Thread, StackTraceElement[]>> entries = all.entrySet();
for(Map.Entry<Thread, StackTraceElement[]> en : entries){
Thread t = en.getKey();
StackTraceElement[] v = en.getValue();
System.out.println("【Thread name is :" + t.getName() + "】");
for(StackTraceElement s : v){
System.out.println("\t" + s.toString());
}
}

例子:

死锁问题:

image-20210713023139008

image-20210713025908544

image-20210713030226778

使用sleep:

image-20210713030341435

同步问题:

image-20210713030512538

8、jcmd:多功能命令行

1、基本介绍

官方帮助文档

在JDK 1.7以后,新增了一个命令行工具jcmd。

它是一个多功能的工具,可以用来实现前面除了jstat之外的所有命令的功能。比如:用它来导出堆、内存使用、查看java进程、导出线程信息、执行GC、JVM运行时间等等。

jcmd拥有jmap的大部分功能,并且在Oracle的官方网站上也推荐使用jcmd命令代替jmap命令。

2、基本语法

image-20210713034508974

  • jcmd -l:列出所有的JVM进程

  • jcmd 进程号 help:针对指定的进程,列出支持的所有具体命令

    • 执行效果:

      img

  • jcmd 进程号 具体命令:显示指定进程的指令命令的数据

    • 首先通过jcmd 进程号 help得出以下命令列表
    • 根据以上命令来替换之前的那些操作:
      • Thread.print 可以替换 jstack指令
      • GC.class_histogram 可以替换 jmap中的-histo操作
      • GC.heap_dump 可以替换 jmap中的-dump操作
      • GC.run 可以查看GC的执行情况
      • VM.uptime 可以查看程序的总执行时间,可以替换jstat指令中的-t操作
      • VM.system_properties 可以替换 jinfo -sysprops 进程id
      • VM.flags 可以获取JVM的配置参数信息

9、jstatd:远程主机信息收集

之前的指令只涉及到监控本机的Java应用程序,而在这些工具中,一些监控工具也支持对远程计算机的监控(如jps、jstat)。为了启用远程监控,则需要配合使用jstatd 工具。

命令jstatd是一个RMI服务端程序,它的作用相当于代理服务器,建立本地计算机与远程监控工具的通信。 jstatd服务器将本机的Java应用程序信息传递到远程计算机。

jstatd的理解

3、JVM监控及诊断工具——GUI篇

1、工具概述

使用上一章命令行工具或组合能帮您获取目标Java应用性能相关的基础信息,但它们存在下列局限:

  1. 无法获取方法级别的分析数据,如方法间的调用关系各方法的调用次数调用时间等(这对定位应用性能瓶颈至关重要)
  2. 要求用户登录到目标Java应用所在的宿主机上,使用起来不是很方便。
  3. 分析数据通过终端输出,结果展示不够直观。

为此,JDK提供 了一些内存泄漏的分析工具,如jconsole、jvisualvm等, 用于辅助开发人员定位问题,但是这些工具很多时候并不足以满足快速定位的需求。所以这里我们介绍的工具相对多一些、丰富一些。

图形化综合诊断工具

  • JDK自带的工具
    • jconsole:JDK自带的可视化监控工具。查看Java应用程序的运行概况、监控堆信息、永久区(或元空间)使用情况、类加载情况
      • 位置:jdk\bin\jconsole.exe
    • Visual VM:Visual VM是一个工具,它提供了一个可视界面,用于查看Java虚拟机上运行的基于Java技术的应用程序的详细信息
      • 位置: jdk\bin\jvisualvm.exe
    • JMC:Java Mission Control,内置Java Flight Recorder**。能够以极低的性能开销收集Java虚拟机的性能数据**。
  • 第三方工具
    • MAT:MAT(Memory Analyzer Tool) 是基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
      • Eclipse的插件形式
    • JProfiler:商业软件,需要付费。功能强大。
      • 与VisualVM类似
    • ArthasAlibaba开源的Java诊断工具。深受开发者喜爱。
    • Btrace:Java运行时追踪工具。可以在不停机的情况下,跟踪指定的方法调用、构造函数调用和系统内存等信息

2、JConsole

1、基本概述

官方教程

jconsole:

  • 从Java5开始,在JDK中自带的java监控和管理控制台。
  • 用于对JVM中内存、线程和类等的监控,是一个基于JMX(java management extensions )的GUI性能监控工具。
2、启动
  • 在jdk安装目录中找到jconsole.exe,双击该可执行文件就可以
  • 打开DOS窗口,直接输入jconsole就可以了
3、三种连接方式
  • Local

    • 使用JConsole连接一个正在本地系统运行的JVM,并且执行程序的和运行JConsole的需要是同一个用户

    • JConsole使用文件系统的授权通过RMI连接起链接到平台的MBean的服务器上。这种从本地连接的监控能力只有Sun的JDK具有。

    • 注意:本地连接要求 启动jconsole的用户运行当前程序的用户同一个用户

    • 具体操作如下:

      1. 在DOS窗口中输入jconsole

        img

      2. 在控制台上填写相关信息

        img

      3. 选择“不安全的连接”

        img

      4. 进入控制台页面

        img

  • Remote

    • 使用下面的URL通过RMI连接器连接到一个JMX代理,service:jmx:rmi:///jndi/rmi://hostName:portNum/jmxrmi。
    • JConsole为建立连接,需要在环境变量中设置mx.remote.credentials来指定用户名和密码,从而进行授权。
  • Advanced

    • 使用一个特殊的URL连接JMX代理。
    • 一般情况使用自己定制的连接器而不是RMI提供的连接器来连接JMX代理,或者是一个使用JDK1.4的实现了JMX和JMX Rmote的应用
4、主要作用
  1. 概览

    img

  2. 内存

    image-20210716160243770

  3. 根据线程检测死锁

    img

  4. 线程

    img

  5. VM 概要

    img

3、Visual VM

1、基本概述
  • VisualVM是一个功能强大的多合一故障诊断和性能监控的可视化工具。

  • 它集成了多个JDK命令行工具,使用VisualVM可用于显示虚拟机进程及进程的配置和环境信息(jps、jinfo),监视应用程序的CPU、GC、堆、方法区及线程的信息(jstat、jstack)等, 也可以代替JConsole。

  • 在JDK 6 Update 7以后,Visual VM便作为JDK的一部分发布(VisualVM在JDK/bin目录下)

    • 即:它完全免费。
  • 此外,Visual VM也可以作为独立的软件安装:

  • 使用:

    1. 在jdk安装目录中找到jvisualvm.exe,然后双击执行即可
    2. 打开DOS窗口,输入jvisualvm就可以打开该软件
2、插件的安装
  • Visual VM的一大特点是支持插件扩展,并且插件安装非常方便。我们既可以通过离线下载插件文件*.nbm,然后在Plugin对话框的已下载页面下,添加已下载的插件。也可以在可用插件页面下,在线安装插件。(这里建议安装上:VisualGC)

  • IDEA安装VisualVM Launcher插件:Preferences –> Plugins –> 搜索VisualVM Launcher,安装重启即可。

    1. 在IDEA中安装插件:首先在IDEA中搜索VisualVM Launcher插件并安装

      img

    2. 重启IDEA,然后配置该插件:

      img

    3. 使用两种方式来运行程序:

      img

    4. 运行效果:还是打开jvisualvm界面,只是不需要我们手动打开jvisualvm而已

3、连接方式
  • 本地连接
    • 监控本地Java进程的CPU、类、线程等
  • 远程连接
    1. 确定远程服务器的ip地址
    2. 添加JMX(通过JMX技术具体监控远程服务器哪个Java进程)
    3. 修改bin/catalina.sh文件,连接远程的tomcat
    4. 在…/conf中添加jmxremote.access和jmxremote.password文件
    5. 将服务器地址改成公网ip地址
    6. 设置阿里云安全策略和防火墙策略
    7. 启动tomcat,查看tomcat启动日志和端口监听
    8. JMX中输入端口号、用户名、密码登录
4、主要功能
  1. 生成/读取/对比堆内存快照

    • 生成堆内存快照:

      1. 方式1:

        img

      2. 方式2:

        img

        注意:

        生成堆内存快照如下图:img

        这些快照存储在内存中,当线程停止的时候快照就会丢失,如果还想利用,可以将快照进行另存为操作,如下图:

        img

    • 装入堆内存快照

      img

    • dump文件对比

      image-20210716164337161

  2. 查看JVM参数和系统属性

  3. 查看运行中的虚拟机进程

  4. 生成/读取线程快照

    • 生成线程快照

      1. 方式1:

        img

      2. 方式2:

        • 注意:

          生成线程快照如下图:img

          这些快照存储在内存中,当线程停止的时候快照就会丢失,如果还想利用,可以将快照进行另存为操作,如下图:

          img

    • 装入线程快照

      img

  5. 程序资源的实时监控

  6. 抽样器

    • CPU

      image-20210716162615803

      image-20210716163047115

      image-20210716163251126

    • 内存

      image-20210716163651100

      image-20210716163925557

  7. 其他功能

    1. JMX代理连接
    2. 远程环境监控
    3. CPU分析和内存分析

4、Eclipse MAT

1、基本概述

MAT(Memory Analyzer Tool)工具是一款功能强大的Java堆内存分析器。可以用于查找内存泄漏以及查看内存消耗情况

MAT是基于Eclipse开发的, 不仅可以单独使用,还可以作为插件的形式嵌入在Eclipse中使用。是一款免费的性能分析工具,使用起来非常方便。大家可以在
下载并使用MAT。

image-20210713175646382

只要确保机器上装有JDK并配置好相关的环境变量,MAT可正常启动。还可以在Eclipse中以插件的方式安装:

image-20210713175744891

注意:如果单独使用,那么解压即可用,不需要安装即可

2、获取堆dump文件
1、dump文件内存

MAT可以分析heap dump文件。 在进行内存分析时,只要获得了反映当前设备内存映像的hprof文件,通过MAT打开就可以直观地看到当前的内存信息。

一般说来,这些内存信息包含:

  • 所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值。
  • 所有的类信息,包括classloader、 类名称、父类、静态变量等GCRoot到所有的这些对象的引用路径
  • 线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)
2、两点说明
  • 说明1:缺点:
    MAT 不是一个万能工具,它并不能处理所有类型的堆存储文件。但是比较主流的厂家和格式,例如 Sun,HP,SAP所采用的HPROF二进制堆存储文件,以及IBM的PHD堆存储文件等都能被很好的解析。

  • 说明2:
    最吸引人的还是能够快速为开发人员生成内存泄漏报表,方便定位问题和分析问题。虽然MAT有如此强大的功能,但是内存分析也没有简单到一键完成的程度,很多内存问题还是需要我们从MAT展现给我们的信息当中通过经验和直觉来判断才能发现。

    image-20210713180221126

3、获取dump文件
  1. 方法一:通过前一章介绍的jmap工具生成,可以生成任意一个java进程的dump文件;

  2. 方法二:通过配置JVM参数生成。

    • 选项”-XX:+HeapDumpOnOutOfMemoryError“ 或”-XX:+HeapDumpBeforeFullGC
    • 选项”-XX:HeapDumpPath“所代表的含义就是当程序出现0utofMemory时, 将会在相应的目录下生成一份dump文件。如果不指定选项“-XX:HeapDumpPath“ 则在当前目录下生成dump文件。
    • 对比:考虑到生产环境中几乎不可能在线对其进行分析,大都是采用离线分析,因此使用jmap+MAT工具是最常见的组合
  3. 方法三:使用VisualVM可以导出堆dump文件

  4. 方法四:使用MAT既可以打开一个已有的堆快照,也可以通过MAT直接从活动Java程序中导出堆快照。该功能将借助jps列出当前正在运行的Java进程,以供选择并获取快照。

    image-20210713180452468

4、加载dump文件

image-20210716175656605

image-20210716175803107

image-20210716180242326

相关解释:

image-20210716180320235

  • Leak Suspects Report(堆泄露疑点报告):
    • 自动检查堆转储是否存在泄漏嫌疑。 报告哪些对象保持活动状态以及为什么它们没有被垃圾回收器回收。
  • Component Report(组件报告):
    • 分析一组对象是否存在疑似内存问题:重复字符串、空集合、终结器、弱引用等
  • Re-open previously run reports(重新打开以前运行的报告):
    • 现有报告存储在堆dump同一目录下的 ZIP 文件中
4、分析堆dump文件

相关图例:

imgimgimg

通过分析堆dump文件可以得到:

  • 是否存在类的重复加载:

    image-20210716183006378

  • 相关的报告:

    image-20210716181822026

    • Heap Dump Overview(堆dump的概述):

      image-20210716182152051

    • Leak Suspects(堆泄露疑点):

      image-20210716182746036

    • Top Components(顶级组件):通过图形列举出最大对象的情况

      image-20210716182455029

  • histogram:展示了各个类的实例数目以及这些实例的Shallow heap或者Retained heap的总和

    • 使用:

      • 图标:

        img

      • 具体内容:

        img

      • 查找一个类:

        1. Group by package (根据包进行分组):(默认是Group by class)

          image-20210716223222052

          image-20210716223315860

        2. 排序:

          image-20210716223613860

        3. 正则表达式:(精准搜索)

          image-20210716223803061

      • 若一个对象可能存在内存泄露(内存泄露疑点),怎么查看?

        image-20210716224553500

      • 将两份内存映像文件的直方图进行对比:(以下图片的”树状图”修改为”直方图”)

        image-20210716225637523

        image-20210716230023991

        image-20210716230040409

  • thread overview

    • 查看系统中的Java线程

    • 查看局部变量的信息

    • 使用:

      • 图标:

        img

      • 具体内容:

        img

        image-20210716232006164

  • 获得对象互相引用的关系

    • with outgoing references(出引用)

      • 图示:

        img

      • 结果:

        img

    • with incoming references(入引用)

      • 图示:

        img

      • 结果:

        img

      • 分析:

        • 若发现此时该对象只有一些生命周期较短的线程(方法/方法里的引用变量)去引用它,则该对象就是可以被GC进行回收,不会存在内存泄露问题
        • 若发现此时该对象还有另外一些生命周期较长的线程(方法/方法里的引用变量)去引用它,则该对象就不能被GC回收,就存在了内存泄露问题。
        • 解决方法:可以将该引用从强引用修改为软引用或弱引用。
  • 浅堆与深堆(与浅拷贝与深拷贝一一对应)

    • shallow heap

      • 浅堆(Shallow Heap)是指一个对象所消耗的内存。在32位系统中,一个对象引用会占据4个字节,一个int类型会占据4个字节,long型变量会占据8个字节,每个对象头需要占用8个字节。根据堆快照格式不同,对象的大小可能会向8字节进行对齐。

      • 以String为例:2个int值共占8字节,对象引用占用4字节,对象头8字节,合计20字节,向8字节对齐,故占24字节。(jdk7中)

        image-20210713181653120

      • 这24字节为String对象的浅堆大小。它与String的value实际取值无关,无论字符串长度如何,浅堆大小始终是24字节。

      • 对象头代表根据类创建的对象的对象头,还有对象的大小不是可能向8字节对齐,而是就向8字节对齐(一定)。

      • 注意一下:这里对象头除去类型指针的大小为8字节,然后类型指针看是否启用了引用压缩,如果启用了,对象头总共就是12字节,否则就是16字节。(32位机是不支持指针压缩的)

    • retained heap

      • 保留集(Retained Set):
        • 对象A的保留集指当对象A被垃圾回收后,可以被释放的所有的对象集合(包括对象A本身),即对象A的保留集可以被认为是只能通过对象A被直接或间接访问到的所有对象的集合。通俗地说,就是指仅被对象A所持有的对象的集合
      • 深堆(Retained Heap):
        • 深堆是指对象的保留集中所有的对象的浅堆大小之和
        • 注意:
          • 浅堆指对象本身占用的内存,不包括其内部引用对象的大小一个对象的深堆指只能通过该对象访问到的(直接或间接)所有对象的浅堆之和,即对象被回收后,可以释放的真实空间
          • 当前深堆大小 = 当前对象的浅堆大小 + 对象中所包含对象的深堆大小
    • 补充:对象实际大小

      • 另外一个常用的概念是对象的实际大小。这里,对象的实际大小定义为一个对象所能触及的(这里要与深堆的”只有通过”**相区分)所有对象的浅堆大小之和,也就是通常意义上我们说的对象大小。与深堆相比,似乎这个在日常开发中更为直观和被人接受,但实际上,这个概念和垃圾回收无关**。

      • 下图显示了一个简单的对象引用关系图,对象A引用了C和D,对象B引用了C和E。那么对象A的浅堆大小只是A本身,不含C和D,而A的实际大小为A、C、D三者之和。而A的深堆大小为A与D之和,由于对象C还可以通过对象B访问到,因此不在对象A的深堆范围内。

        image-20210713182436604

    • 练习

      • 看图理解Retained Size

        image-20210713182530946

      • 解答:

        • 上图中,GC Roots直接引用了A和B两个对象。
        • A对象的Retained Size = A对象的Shallow Size
        • B对象的Retained Size = B对象的Shallow Size + C对象的Shallow Size
        • 这里不包括D对象,因为D对象被GC Roots直接引用。
      • 如果GC Roots不引用D对象呢?

        • 那么B对象的Retained Size = B对象的Shallow Size + C对象的Shallow Size + D对象的Shallow Size
        • 因为此时的D对象只有通过B对象进行引用
    • 案例分析:StudentTrace

      • 代码:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        76
        77
        78
        79
        80
        81
        82
        83
        84
        85
        86
        87
        88
        89
        90
        /**
        * 有一个学生浏览网页的记录程序,它将记录 每个学生访问过的网站地址。
        * 它由三个部分组成:Student、WebPage和StudentTrace三个类
        *
        * -XX:+HeapDumpBeforeFullGC -XX:HeapDumpPath=d:\student.hprof
        * @author shkstart
        * @create 16:11
        */
        public class StudentTrace {
        static List<WebPage> webpages = new ArrayList<WebPage>();

        public static void createWebPages() {
        for (int i = 0; i < 100; i++) {
        WebPage wp = new WebPage();
        wp.setUrl("http://www." + Integer.toString(i) + ".com");
        wp.setContent(Integer.toString(i));
        webpages.add(wp);
        }
        }
        public static void main(String[] args) {
        createWebPages();//创建了100个网页
        //创建3个学生对象
        Student st3 = new Student(3, "Tom");
        Student st5 = new Student(5, "Jerry");
        Student st7 = new Student(7, "Lily");
        for (int i = 0; i < webpages.size(); i++) {
        if (i % st3.getId() == 0)
        st3.visit(webpages.get(i));
        if (i % st5.getId() == 0)
        st5.visit(webpages.get(i));
        if (i % st7.getId() == 0)
        st7.visit(webpages.get(i));
        }
        webpages.clear();
        System.gc();
        }
        }
        class Student {
        private int id;
        private String name;
        private List<WebPage> history = new ArrayList<>();

        public Student(int id, String name) {
        super();
        this.id = id;
        this.name = name;
        }

        public int getId() {
        return id;
        }
        public void setId(int id) {
        this.id = id;
        }
        public String getName() {
        return name;
        }
        public void setName(String name) {
        this.name = name;
        }
        public List<WebPage> getHistory() {
        return history;
        }
        public void setHistory(List<WebPage> history) {
        this.history = history;
        }
        public void visit(WebPage wp) {
        if (wp != null) {
        history.add(wp);
        }
        }
        }

        class WebPage {
        private String url;
        private String content;

        public String getUrl() {
        return url;
        }
        public void setUrl(String url) {
        this.url = url;
        }
        public String getContent() {
        return content;
        }
        public void setContent(String content) {
        this.content = content;
        }
        }
      • 图示:

        img

      • 结论:

        • 这里三个学生对象的浅堆大小都是24字节:4(id) + 4(name) + 4(history) + 8(对象头) = 20 –> 24(向8字节对齐)
          • 其实这里如果是32位虚拟机,需要补对齐填充。
          • 但如果不是是32位虚拟机,而且存在类型指针压缩的话:对象头应该有12字节 –> 4 + 4 + 4 + 12 = 24
          • 普通Java对象头的大小为12字节或16字节,默认采用了指针压缩则为12字节,没有采用则为16字节(数组还需要加上数组长度)
          • 同理每一个网页的浅堆大小也都是24字节4(url) + 4(content) + 12(对象头) = 20 –> 24(向8字节对齐)
      • 解释:(为什么elementData数组的深堆为1288个字节)

        • 普通Java对象头的大小为12字节或16字节。默认采用了指针压缩则为12字节,没有采用则为16字节(数组还需要加上数组长度)。详情参考博客
        • 为什么有152字节和144字节:
          • 因为我们的URL和content存在两种情况(个位数与十位数)
          • 第一种URL长度为16,底层的char数组的占用空间为(【】方括号里面整个都属于对象头,分开写方便大家理解)
            • 【普通对象头(12) + 数组长度(4)】 + 16个字符(32) = 48字节,符合8字节对齐
            • 同理content 占用 【普通对象头(12) +数组长度(4)】+ 一个字符(2) = 18字节,八字节对齐 = 24字节
          • 第二种URL长度为17,底层的插入数组的占用空间为
            • 【普通对象头(12) + 数组长度(4)】 + 17个字符(34) = 50字节,不符合8字节对齐,对齐为56
            • 同理content 占用 【普通对象头(12) +数组长度(4)】+ 两个字符(4) = 20字节,八字节对齐 = 24字节
          • 所以第一种总字节为48 + 24 = 72,第二种总字节为56 + 24 = 80。因此第二种比第一种多了8字节,所以是152和144
          • 为什么总大小是152而不是72?
            • 因为我们只计算了String底层的char数组的区别没有计算各变量本身的浅堆,因为结构都相同,所以差别就差在内容的占用上
        • 为什么最终结果是1288?
          • 首先ElementData数组本身的浅堆大小为:【普通对象头(12) + 数组长度(4)】 + 数组内容【15个Obejct引用 = 15 * 4】 = 76,八字节对齐 = 80字节
          • 15个Object分为13个152字节 + 2个144字节,总大小为 = 2264字节
          • 7号和其他student重复的有0、21、42、63、84、35、70总计6个152和1一个144
          • 所以2264 - 6 * 152 - 144 = 1208字节
          • 所以ElementData本身的浅堆80 + 仅能通过它到达的浅堆1208 = 1288
        • 为什么ArrayList的长度是15?(并不是因为ArrayList的内容是15个,对于Jerry同学来说:ArrayList的长度是22,但是ArrayList的内容只有21)
          • 这是和ArrayList的扩容有关
          • ArrayList默认的长度为10,当长度超过10的时候,ArrayList就会自动扩容,扩容系数是0.5
          • 即ArrayList的长度 = 10 * 1.5 = 15
          • 当超过扩容后的长度(15),ArrayList会再次扩容:15 * 1.5 = 22
  • 支配树

    • 支配树(Dominator Tree )(支配树的概念源自图论(统计学))

    • MAT提供了一个称为支配树(Dominator Tree) 的对象图。支配树体现了对象实例间的支配关系。在对象引用图中,所有指向对象B的路径都经过对象A,则认为对象A支配对象B。如果对象A是离对象B最近的一个支配对象,则认为对象A为对象B的直接支配者。支配树是基于对象间的引用图所建立的,它有
      以下基本性质:

      • 对象A的子树(所有被对象A支配的对象集合)表示对象A的保留集(retained set),即深堆。
      • 如果对象A支配对象B,那么对象A的直接支配者也支配对象B。
      • 支配树的边与对象引用图的边不直接对应。
    • 如下图所示:左图表示对象引用图,右图表示左图所对应的支配树。对象A和B由根对象直接支配,由于在到对象C的路径中,可以经过A,也可以经过B,因此对象C的直接支配者也是根对象。对象F与对象D相互引用,因为到对象F的所有路径必然经过对象D,因此,对象D是对象F的直接支配者。而到对象D的所有路径中,必然经过对象C,即使是从对象F到对象D的引用,从根节点出发,也是经过对象C的,所以,对象D的直接支配者为对象C。同理,对象E支配对象G。到达对象H的可以通过对象D,也可以通过对象E,因此对象D和E都不能支配对
      象H,而经过对象C既可以到达D也可以到达E,因此对象C为对象H的直接支配者。

      image-20210713185747798

    • 注意:

      • 跟随我一起来理解如何从“对象引用图—》支配树”,首先需要理解支配者(如果要到达对象B,毕竟经过对象A,那么对象A就是对象B的支配者,可以想到支配者大于等于1),
      • 然后需要理解直接支配者(在支配者中距离对象B最近的对象A就是对象B的直接支配者,你要明白直接支配者不一定就是对象B的上一级,然后直接支配者只有一个),
      • 然后还需要理解支配树是怎么画的,其实支配树中的对象与对象之间的关系就是直接支配关系,也就是上一级是下一级的直接支配者,只要按照这样的方式来作图,肯定能从“对象引用图 —》支配树”
    • 在Eclipse MAT工具中如何查看支配树:

      • 在MAT中,单击工具栏上的对象支配树按钮,可以打开对象支配树视图。

        image-20210714000259424

      • 下图显示了对象支配树视图的一部分。该截图显示部分Lily学生的history队列的直接支配对象。即当Lily对象被回收,也会一并回收的所有对象。显然能被3或者5整除的网页不会出现在该列表中,因为它们同时被另外两名学生对象引用。(15(总) - 7(被其他引用) = 8(可回收))

        image-20210714000337354

4、案例:Tomcat堆溢出分析
  • 说明:

    • Tomcat是最常用的Java Servlet容器之一 , 同时也可以当做单独的Web服务器使用。Tomcat本身使用Java实现,并运行于Java虚拟机之上。在大规模请求时,Tomcat有可能会因为无法承受压力而发生内存溢出错误。这里根据一个被压垮的Tomcat的堆快照文件, 来分析Tomcat在崩溃时的内部情况。
  • 分析过程:

    • 查看大对象(主要分析的对象):

      image-20210717034430939

    • 查看当前最大的对象它到底引用了哪些具体的内部结构:

      image-20210717034529989

    • 查看该大对象中哪一部分占用了大部分内存:

      image-20210717034627788

    • 继续往里查看:

      image-20210717034749850

    • 继续往里查看:

      image-20210717034836025

    • 找到出现问题的对象后,可以通过OOL语句查询出想要的对象

      img

      img

    • 查看该对象的创建时间与结束时间,判断他是不是一个生命周期短的对象:

      img

    • 根据找到的信息进行分析:

      img

5、支持使用OQL语言查询对象信息

image-20210716181538800

  • SELECT子句
  • FROM子句
  • WHERE子句
  • 内置对象与方法

5、JProfiler

1、基本概述
1、介绍

官网下载地址

在运行Java的时候有时候想测试运行时占用内存情况,这时候就需要使用测试工具查看了。在eclipse里面有Eclipse Memory Analyzer tool (MAT)插件可以测试,而在IDEA中也有这么一个插件,就是JProfiler。

JProfiler是由ej-technologies公司开发的一款Java应用性能诊断工具。功能强大,但是收费。

2、特点
  • 使用方便、界面操作友好( 简单且强大)
  • 对被分析的应用影响小(提供模板)
  • CPU, Thread , Memory分析功能尤其强大
  • 支持对jdbc、noSql、jsp、servlet、socket等进行分析
  • 支持多种模式(离线,在线)的分析
  • 支持监控本地、远程的JVM
  • 跨平台,拥有多种操作系统的安装版本

image-20210717185256510

3、主要功能
  1. 方法调用
    • 对方法调用的分析可以帮助您了解应用程序正在做什么,并找到提高其性能的方法
  2. 内存分配
    • 通过分析堆上对象、引用链和垃圾收集能帮您修复内存泄露问题,优化内存使用
  3. 线程和锁
    • JProfiler提供多种针对线程和锁的分析视图助您发现多线程问题
  4. 高级子系统
    • 许多性能问题都发生在更高的语义级别上。例如,对于JDBC调用,您可能希望找出执行最慢的SQL语句。JProfiler支持对这些子系统进行集成分析
2、安装与配置
1、下载与安装

下载

image-20210717190718552

image-20210717195706559

2、JProfiler中配置IDEA
  1. IDE Integrations

    img

  2. 选择合适的IDE版本

    img

  3. 开始集成

    img

  4. 正式集成

    img

  5. 集成成功

    img

  6. 点击OK即可

3、IDEA集成JProfiler
  1. 安装JProfiler插件

    1. 方式1:在线安装

      img

    2. 方式2、离线安装

      1. 首先下载插件:

        img

      2. 准备离线安装:

        img

      3. 正式离线安装:

        img

      注意:无论采用方式1还是方式2都需要重启IDEA

  2. 将JProfiler配置到IDEA中

    img

3、具体使用
  • 启动:

    image-20210717220513329

    • 相关说明:
      • Profile a demo session or a saved session(配置demo会话或保存一个会话):
        • JProfiler附带了几个预先配置的演示会话。你可以让他们开始探索JProfiler的特征。
      • Attach to a running JVM(连接到正在运行的JVM):
        • JProfiler可以连接到本地或远程运行的jvm,并动态地分析它们。一些附加模式下不支持功能。
      • Profile an application server, locally or remotely(本地或远程配置应用程序服务器):
        • JProfiler提供了对所有主要应用服务器的广泛支持。两个应用服务器支持在此计算机和远程计算机上运行。
      • Open a snapshot(打开快照):
        • JProfiler可以保存快照以及以后可以打开的所有分析结果。而且,它可以打开HPROF和PHD快照。
  • 数据采集方式

    image-20210717221829151

    • JProfier数据采集方式分为两种:Sampling(样本采集)和Instrumentation (重构模式)
      • Instrumentation:这是JProfiler全功能模式。在class加载之前,JProfier把相关功能代码写入到需要分析的class的bytecode中,对正在运行的jvm有一定影响。
        • 优点:功能强大。在此设置中,调用堆栈信息是准确的。
        • 缺点:若要分析的class较多,则对应用的性能影响较大,CPU开销可能很高(取决于Filter的控制)。因此使用此模式一般配合Filter使用,只对特定的类或包进行分析
      • Sampling:类似于样本统计,每隔一定时间(5ms )将每个线程栈中方法栈中的信息统计出来。
        • 优点:对CPU的开销非常低,对应用影响小(即使你不配置任何Filter)
        • 缺点:一些数据/特性不能提供(例如:方法的调用次数、执行时间)
      • 注:JProfiler本身没有指出数据的采集类型,这里的采集类型是针对方法调用的采集类型。因为JProfiler的绝大多数核心功能都依赖方法调用采集的数据,所以可以直接认为是JProfiler的数据采集类型。
      • 推荐使用Sampling方式,足够用来分析OOM问题了
  • 遥感监测 Telemetries

    img

    • 其中Telemetries就是遥感监测的意思
  • 内存视图 Live Memory

    • Live memory(内存剖析):class/class instance的相关信息。例如对象的个数, 大小,对象创建的方法执行栈,对象创建的热点。

      • 所有对象All Objects:显示所有加载的类的列表和在堆上分配的实例数。只有Java 1.5 (JVMTI)才会显示此视图。(浅堆)

        image-20210717192307939

      • 记录对象Record Objects:查看特定时间段对象的分配,并记录分配的调用堆栈。

        • 注意:默认关闭,若开启的话,会导致系统的性能急剧的降低。

        • 开启的时机:判断内存泄露的时候开启

        • 使用:

          image-20210717223524175

      • 分配访问树Allocation Call Tree:显示一棵请求树或者方法、 类、包或对己选择类有带注释的分配信息的J2EE组件。

      • 分配热点Allocation Hot Spots:显示一个列表,包括方法、类、包或分配已选类的J2EE组件。你可以标注当前值并且显示差异值。对
        于每个热点都可以显示它的跟踪记录树。
        类追踪器Class Tracker:类跟踪视图可以包含任意数量的图表,显示选定的类和包的实例与时间。

    • 分析:内存中的对象的情况

      • 频繁创建的Java对象:死循环、循环次数过多
      • 存在大的对象:读取文件时,byte[]应该边读边写。–> 如果长时间不写出的话,导致byte[]过大
      • 存在内存泄漏
    • 注意:

      1. All Objects后面的Size大小是浅堆大小
      2. Record Objects在判断内存泄露的时候使用,可以通过观察Telemetries中的Memory,如果里面出现垃圾回收之后的内存占用逐步提高,这就有可能出现内存泄露问题,所以可以使用Record Objects查看,但是该分析默认不开启,毕竟占用CPU性能太多
  • 堆遍历 heap walker

    • 如果通过内存视图 Live Memory已经分析出哪个类的对象不能进行垃圾回收,并且有可能导致内存溢出,如果想进一步分析,我们可以在该对象上点击右键,选择Show Selection In Heap Walker,如下图:

      img

    • 之后进行溯源,操作如下:

      img

    • 查看结果,并根据结果去看对应的图表:

      img

    • 以下是图表的展示情况:

      img

    • 对于堆快照:

      image-20210717224343240

  • cpu视图 cpu views

    image-20210717224827793

    1. 具体使用:

      1. 访问树

        image-20210717225913334

      2. 记录方法统计信息

        img

      3. 方法统计

        img

      4. 具体分析

        img

  • 线程视图 threads

    • JProfiler通过对线程历史的监控判断其运行状态,并监控是否有线程阻塞产生,还能将一个线程所管理的方法以树状形式呈现。对线程剖析。

      • 线程历史Thread History:显示一个与线程活动和线程状态在一起的活动时间表。
      • 线程监控Thread Monitor:显示一个列表,包括所有的活动线程以及它们目前的活动状况。
      • 线程转储Thread Dumps:显示所有线程的堆栈跟踪。
    • 线程分析主要关心三个方面:

      1. web容器的线程最大数。比如: Tomcat的线程容量应该略大于最大并发数。
      2. 线程阻塞
      3. 线程死锁
    • 具体使用:

      1. 查看线程运行情况

        img

      2. 新建线程dump文件

        img

  • 监视器&锁 Monitors&locks

    • 监控和锁Monitors & Locks所有线程持有锁的情况以及锁的信息。
    • 观察JVM的内部线程并查看状态:
      • 死锁探测图表Current Locking Graph:显示JVM中的当前死锁图表。
      • 目前使用的监测器CurrentMonitors:显示目前使用的监测器并且包括它们的关联线程。
      • 锁定历史图表Locking History Graph:显示记录在JVM中的锁定历史。
      • 历史检测记录MonitorHistory:显示重大的等待事件和阻塞事件的历史记录。
      • 监控器使用统计Monitor Usage Statistics:显示分组监测,线程和监测类的统计监测数据
4、案例分析
1、案例1(较为安全)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class JProfilerTest {
public static void main(String[] args) {
while (true){
ArrayList list = new ArrayList();
for (int i = 0; i < 500; i++) {
Data data = new Data();
list.add(data);
}
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Data{
private int size = 10;
private byte[] buffer = new byte[1024 * 1024];//1mb
private String info = "hello,atguigu";
}
2、案例2(内存泄露)

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MemoryLeak {
public static void main(String[] args) {
while (true) {
ArrayList beanList = new ArrayList();
for (int i = 0; i < 500; i++) {
Bean data = new Bean();
data.list.add(new byte[1024 * 10]);//10kb
beanList.add(data);
}
try {
TimeUnit.MILLISECONDS.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class Bean {
int size = 10;
String info = "hello,atguigu";
// ArrayList list = new ArrayList(); // 解决方法
static ArrayList list = new ArrayList();
}

分析:

我们通过JProfiler来看一下,如下:

img

你可以看到内存一个劲的往上涨,但是就是没有下降的趋势,说明这肯定有问题,过不了多久就会出现OOM,我们来到Live memory中,先标记看一下到底是哪些对象在进行内存增长,等一小下看看会不会触发垃圾回收,如果不触发的话,我们自己来触发垃圾回收,之后观察哪些对象没有被回收掉,如下:

img

我上面点击了Mark Current,发现有些对象在持续增长,然后点击了一下Run GC,结果如下所示:

img

可以看出byte[]没有被回收,说明它是有问题的,我们点击Show Selection In Heap Walker,如下:

img

然后看一下该对象被谁引用,如下:

img

结果如下:

img

可以看出byte[]来自于Bean类是的list中,并且这个list是ArrayList类型的静态集合,所以找到了:static ArrayList list = new ArrayList();

发现list是静态的,这不妥,因为我们的目的是while结束之后Bean对象被回收,并且Bena对象中的所有字段都被回收,但是list是静态的,那就是类的,众所周知,类变量随类而生,随类而灭,因此每次我们往list中添加值,都是往同一个list中添加值,这会造成list不断增大,并且不能回收,所以最终会导致OOM

6、Arthas

1、基本概述
1、背景

前面,我们介绍了jdk自带的jvisualvm等免费工具,以及商业化工具Jprofiler。

jvisualvm界面:

image-20210718010802170

Jprofiler界面:

image-20210718010823344

这两款工具在业界知名度也比较高,他们的优点是可以图形界面上看到各维度的性能数据,使用者根据这些数据进行综合分析,然后判断哪里出现了性能问题。

但是这两款工具也有个缺点,都必须在服务端项目进程中配置相关的监控参数。然后工具通过远程连接到项目进程,获取相关的数据。这样就会带来一些不便,比如线上环境的网络是隔离的,本地的监控工具根本连不上线上环境。并且类似于Jprofiler这样的商业工具,是需要付费的。

那么有没有一款工具不需要远程连接,也不需要配置监控参数,同时也提供了丰富的性能监控数据呢?

今天跟大家介绍一款阿里巴巴开源的性能分析神器Arthas (阿尔萨斯)

2、概述

image-20210718011032691

Arthas (阿尔萨斯)是Alibaba开源的Java诊断工具, 深受开发者喜爱。在线排查问题,无需重启;动态跟踪Java代码;实时监控JVM状态。

Arthas支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的Tab自动补全功能,进-步方便进行问题的定位和诊断。

当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:

  • 这个类从哪个jar包加载的?为什么会报各种类相关的Exception?
  • 我改的代码为什么没有执行到?难道是我没commit?分支搞错了?
  • 遇到问题无法在线上debug,难道只能通过加日志再重新发布吗?
  • 线上遇到某个用户的数据处理有问题,但线上同样无法debug,线下无法重现!
  • 是否有一个全局视角来查看系统的运行状况?
  • 有什么办法可以监控到JVM的实时运行状态?
  • 怎么快速定位应用的热点,生成火焰图?
3、基于哪些工具开发而来
  • greys-anatomyArthas代码基于Greys二次开发而来,非常感谢Greys之前所有的工作,以及Greys原作者对Arthas提出的意见和建议!
  • termdArthas的命令行实现基于termd开发,是一款优秀的命令行程序开发框架,感谢termd提供了优秀的框架。
  • crashArthas的文本渲染功能基于crash中的文本渲染功能开发,可以从这里看到源码,感谢crash在这方面所做的优秀工作。
  • cliArthas的命令行界面基于vert.x提供的cli库进行开发,感谢vert. x在这方面做的优秀工作。
  • compiler:Arthas里的内存编绎器代码来源
  • Apache Commons Net:Arthas里的Telnet Client代码来源
  • JavaAgent:运行在main方法之前的拦截器,它内定的方法名叫premain ,也就是说先执行premain方法然后再执行main方法
  • ASM:一个通用的Java字节码操作和分析框架。它可以用于修改现有的类或直接以二进制形式动态生成类。ASM提供了一些常见的字节码转换和分析算法,可以从它们构建定制的复杂转换和代码分析工具。ASM提供了与其他Java字节码框架类似的功能,但是主要关注性能。因为它被设计和实现得尽可能小和快,所以非常适合在动态系统中使用(当然也可以以静态方式使用,例如在编译器中)
4、官方使用文档

https://arthas.aliyun.com/doc/quick-start.html

2、安装与使用
1、安装
  1. 安装方式一:可以直接在Linux 上通过命令下载

    • 可以在官方Github 上进行下载,如果速度较慢,可以尝试国内的码云Gitee 下载。

      • github 下载

        1
        wget https://alibaba.github.io/arthas/arthas-boot.jar
      • Gitee下载

        1
        wget https://arthas.gitee.io/arthas-boot.jar
  2. 安装方式二:

    • 也可以在浏览器直接访问,等待下载成功后,上传到Linux服务器上。(可以放在opt文件目录下)

      image-20210718011406059

卸载:

  • 在Linux/Unix/Mac 平台

  • 删除下面文件:

    1
    2
    rm -rf ~/.arthas/I
    rm -rf ~/logs/arthas
  • Windows平台直接删除user home下面的.arthas和logs/arthas目录

2、工程目录
  • arthas-agent:基于JavaAgent技术的代理
  • bin:一些启动脚本
  • arthas-boot:Java版本的一键安装启动脚本
  • arthas-client:telnet client代码
  • arthas-common:一些共用的工具类和枚举类
  • arthas-core:核心库,各种arthas命令的交互和实现
  • arthas-demo:示例代码
  • arthas-memorycompiler:内存编绎器代码,Fork from https://github.com/skalogs/SkaETL/tree/master/compiler
  • arthas-packaging:maven打包相关的
  • arthas-site:arthas站点
  • arthas-spy:编织到目标类中的各个切面
  • static:静态资源
  • arthas-testcase:测试
3、启动

Arthas只是一个java程序,所以可以直接用java -jar 运行。

执行成功后,arthas提供了一种命令行方式的交互方式,arthas 会检测当前服务器上的Java进程,并将进程列表展示出来,用户输入对应的编号(1、2、3、4……)进行选择,然后回车。

比如:

  1. 方式1:

    1
    java -jar arthas-boot.jar

    #选择进程(输入[]内编号(不是PID)回车)
    [INFO] arthas-boot version: 3.1.4
    [INFO] Found existing java process, please choose one and hit RETURN.

    * [1]: 11616 com.Arthas

    [2]: 8676

    [3]: 16200 org. jetbrains. jps . cmdline . Launcher

    [4]: 21032 org. jetbrains. idea . maven. server . RemoteMavenServer

  2. 方式2:

    运行时选择Java进程PID:

    1
    java -jar arthas-boot.jar [PID]

image-20210718011816040

4、查看进程

jps

5、查看日志

cat ~/logs/arthas/arthas.log

6、查看帮助

java -jar arthas-boot.jar -h

7、web console

除了在命令行查看外,Arthas目前还支持Web Console。在成功启动连接进程之后就已经自动启动,可以直接访问http://127.0.0.1:8563/ 访问,页面上的操作模式和控制台完全一样。

image-20210718012104569

8、退出

最后一行[arthas@7457]$, 说明打开进入了监控客户端,在这里就可以执行相关命令进行查看了。

  • 使用quit\exit:退出当前客户端
  • 使用stop\shutdown:关闭arthas服务端,并退出所有客户端。
3、相关诊断指令
1、基础指令

image-20210718012307430

2、jvm相关

命令列表:https://arthas.aliyun.com/doc/commands.html#id1

image-20210718012842725

3、class/classloader相关

image-20210718014201542

  • sc

    • 作用:查看JVM已加载的类信息
    • 链接:https://arthas.aliyun.com/doc/sc
    • 常用参数:
      • class- pattern:类名表达式匹配
      • -d:输出当前类的详细信息,包括这个类所加载的原始文件来源、类的声明、加载的ClassLoader等详细信息。如果一个类被多个ClassLoader所
        加载,则会出现多次
      • -E:开启正则表达式匹配,默认为通配符匹配
      • -f:输出当前类的成员变量信息(需要配合参数-d一起使用)
      • -x:指定输出静态变量时属性的遍历深度,默认为0,即直接使用toString输出
    • 补充:
      1. class-pattern支持全限定名, 如com.test.AAA,也支持com/test/AAA这样的格式,这样,我们从异常堆栈里面把类名拷贝过来的时候,不需要在手动把/替换为.了。
      2. sc默认开启了子类匹配功能,也就是说所有当前类的子类也会被搜索出来,想要精确的匹配,请打开options disable-sub-class true开关
  • sm

    • 作用:查看己加载类的方法信息
    • 链接:https://arthas.aliyun.com/doc/sm
    • sm命令只能看到由当前类所声明(declaring) 的方法,父类则无法看到
    • 常用参数:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • -d:展示每个方法的详细信息
      • -E:开启正则表达式匹配,默认为通配符匹配
  • jad

    • 作用:反编译指定己加载类的源码

    • 链接:https://arthas.aliyun.com/doc/jad

    • 在Arthas Console 上,反编译出来的源码是带语法高亮的,阅读更方便

    • 当然,反编译出来的java代码可能会存在语法错误,但不影响你进行阅读理解

    • 编译java.lang.String

      image-20210718004243898

  • mc、redefine

    • mc命令:Memory Compiler/内存编译器,编译.java文件生成.class

    • 链接:https://arthas.aliyun.com/doc/mc

    • 使用:

      1
      mc /tmp/Test.java
    • redefine命令:加载外部的.class文件,redefine jvm已加载的类。

    • 链接:https://arthas.aliyun.com/doc/redefine

    • 推荐使用retransform命令

      1
      2
      redefine /tmp/Test.class 
      redefine -c 327a647b /tmp/Test.class /tmp/Test\$Inner.class
  • classloader

    • 作用:查看classloader 的继承树,urls,类加载信息
    • 链接:https://arthas.aliyun.com/doc/classloader
    • 了解当前系统中有多少类加载器,以及每个加载器加载的类数量,帮助您判断是否有类加载器泄漏。
    • 常用参数:
      • -t:查看ClassLoader的继承树
      • -l:按类加载实例查看统计信息
      • -c:用classloader对应的hashcode来查看对应的jar urls
4、monitor/watch/trace相关

命令列表:https://arthas.aliyun.com/doc/commands.html#id1

image-20210718014942945

  • monitor

    • monitor命令:方法执行监控

    • 对匹配class-pattern / method-pattern的类、方法的调用进行监控。涉及方法的调用次数、执行时间、失败率等

    • 链接:https://arthas.aliyun.com/doc/monitor

    • monitor命令是一个非实时返回命令

    • 常用参数:

      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • -c:统计周期,默认值为120秒
    • 监控项:

      image-20210718015103331

  • watch

    • watch命令:方法执行数据观测
    • 链接:https://arthas.aliyun.com/doc/watch
    • 作用:让你能方便的观察到指定方法的调用情况。能观察到的范围为:返回值、抛出异常、入参,通过编写groovy表达式进行对应变量的查看。
    • 常用参数:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • express:观察表达式
      • condition-express:条件表达式
      • -b:在方法调用之前观察(默认关闭)
      • -e:在方法异常之后观察(默认关闭)
      • -s:在方法返回之后观察(默认关闭)
      • -f:在方法结束之后(正常返回和异常返回)观察(默认开启)
      • -x:指定输出结果的属性遍历深度,默认为0
      • #cost:方法执行耗时
    • 说明:这里重点要说明的是观察表达式,观察表达式的构成主要由ognl 表达式组成,所以你可以这样写”{params, returnObj}”,只要是一个合法的ognl表达式,都能被正常支持。
    • 举例:watch全限定类名 方法名returnObj
  • trace

    • trace命令:方法内部调用路径,并输出方法路径上的每个节点上耗时
    • 链接:https://arthas.aliyun.com/doc/trace
    • 补充说明:
      • trace命令能主动搜索class-pattern / method- pattern 对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路。
      • trace能方便的帮助你定位和发现因RT高而导致的性能问题缺陷,但其每次只能跟踪一级方法的调用链路
      • trace 在执行的过程中本身是会有一定的性能开销,在统计的报告中并未像JProfiler一样预先减去其自身的统计开销。所以这统计出来有些许的不准,渲染路径上调用的类、方法越多,性能偏差越大。但还是能让你看清一些事情的。
    • 参数说明:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • condition-express:条件表达式
      • -n:命令执行次数
      • #cost:方法执行耗时
  • stack

    • stack命令:输出当前方法被调用的调用路径
    • 链接:https://arthas.aliyun.com/doc/stack
    • 常用参数:
      • class-pattern:类名表达式匹配
      • method-pattern:方法名表达式匹配
      • condition-express:条件表达式
      • -n:执行次数限制
      • #cost:方法执行耗时
  • tt

    • tt命令:方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测。
    • 链接:https://arthas.aliyun.com/doc/tt
    • TimeTunnel的缩写
    • 常用参数:
      • -t:表明希望记录下类 *Test 的 print 方法的每次执行情况。
      • -n 3:指定你需要记录的次数,当达到记录次数时Arthas会主动中断tt命令的记录过程,避免人工操作无法停止的情况。
      • -s:筛选指定方法的调用信息
      • -i:参数后边跟着对应的INDEX编号查看到它的详细信息
      • -p:重做一次调用,通过--replay-times 指定调用次数,通过--replay- interval 指定多次调用间隔(单位ms,默认1000ms)
5、其他

使用>将结果重写到日志文件,使用&指令命令是后台运行,session断开不影响任务执行(生命周期默认为1天)

  • jobs:列出所有job
  • kill:强制终止任务
  • fg:将暂停的任务拉到前台执行
  • bg:将暂停的任务放到后台执行
  • grep:搜索满足条件的结果
  • plaintext:将命令的结果去除ANSI颜色
  • wc:按行统计输出结果
  • options:查看或设置Arthas全局开关
  • profiler:使用async-profiler对应用采样,生成火焰图

7、Java Misssion Control

1、历史

在Oracle 收购Sun之前,Oracle 的JRockit 虚拟机提供了一款叫做JRockitMission Control的虛拟机诊断工具。

在Oracle收购Sun之后,Oracle公司同时拥有了Sun Hotspot和JRockit两款虚拟机。根据Oracle对于Java的战略,在今后的发展中,会将JRockit的优秀特性移植到Hotspot上。其中一个重要的改进就是在Sun的JDK中加入了JRockit的支持。

在Oracle JDK 7u40之后,Mission Control这款工具已经绑定在Oracle JDK中发布。

自Java 11开始,本节介绍的JFR已经开源。但在之前的Java版本,JFR属于Commercial Feature,可要通过Java虚拟机参数-XX: +UnlockCommercialFeatures开启。

如果你有兴趣请可以查看OpenJDK的Mission Control项目

2、启动

Mission Control位于%JAVA_ HOME%/bin/jmc.exe,打开这款软件。

image-20210718021714996

3、概述

Java Mission Control (简称JMC) Java官方提供的性能强劲的工具,是一个用于对Java 应用程序进行管理、监视、概要分析和故障排除的工具套件。

它包含一个GUI客户端,以及众多用来收集Java虚拟机性能数据的插件,如JMX Console(能够访问用来存放虚拟机各个子系统运行数据的MXBeans),以及虚拟机内置的高效profiling 工具Java Flight Recorder (JFR)

JMC 的另一个优点就是:采用取样,而不是传统的代码植入技术,对应用性能的影响非常非常小,完全可以开着JMC来做压测(唯一影响可能是full gc多了)。

4、功能:实时监控JVM运行时的状态

如果是远程服务器,使用前要开JMX。

-Dcom. sun. management . jmxremote . port=${YOUR PORT}
-Dcom. sun. management . jmxremote
-Dcom. sun. management . imxremote . authenticate=false
-Dcom. sun . management . jmxremote. ss1=false
-Djava. rmi. server . hostname=${YOUR HOST/IP}

文件 -> 连接 ->创建新连接,填入上面JMX参数的host和port

image-20210718022620212

5、Java Flight Recorder

Java Flight Recorder是JMC 的其中一个组件。

Java Flight Recorder能够以极低的性能开销收集Java虚拟机的性能数据。

JFR的性能开销很小,在默认配置下平均低于1%**。与其他工具相比,JFR能够直接访问虚拟机内的数据,并且不会影响虚拟机的优化。因此,它非常适用于生产环境下满负荷运行的Java程序**。

Java Flight Recorder和JDK Mission Control共同创建了一个完整的工具链。JDK Mission Control可对Java Flight Recorder连续收集低水平和详细的运行时信息进行高效详细的分析。

  • 事件类型

    • 当启用时,JFR 将记录运行过程中发生的一系列事件。其中包括Java层面的事件,如线程事件、锁事件,以及Java 虚拟机内部的事件,如新建对象、垃圾回收和即时编译事件。
    • 按照发生时机以及持续时间来划分,JFR 的事件共有四种类型,它们分别为以下四种:
      1. **瞬时事件(Instant Event)**:用户关心的是它们发生与否,例如异常、线程启动事件。
      2. **持续事件(Duration Event)**:用户关心的是它们的持续时间,例如垃圾回收事件。
      3. **计时事件(Timed Event)**:是时长超出指定阈值的持续事件。
      4. **取样事件(Sample Event)**:是周期性取样的事件。
    • 取样事件的其中一个常见例子便是方法抽样(Method Sampling),即每隔一段时间统计各个线程的栈轨迹。如果在这些抽样取得的栈轨迹中存在一个反复出现的方法,那么我们可以推测该方法是热点方法
  • 启动方式

    1. 方式1:使用-XX:StartFlightRecording=参数

      • 比如:下面命令中,JFR将会在Java虚拟机启动5s后(对应delay=5s)收集数据,持续20s (对应duration=20s)。当收集完毕后,JFR 会将收集得到的数据保存至指定的文件中(对应filename=myrecording . jfr)

        1
        2
        java
        -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,setting s=profile MyApp
      • 由于JFR将持续收集数据,如果不加以限制,那么JFR可能会填满硬盘的所有空间。因此,我们有必要对这种模式下所收集的数据进行限制。

        • 比如:

          1
          java -XX:StartFlightRecording=maxage=10m, maxsize=100m, name=SomeLabel MyApp
    2. 方式2:使用jcmd的JFR.*子命令

      • 通过jcmd来让JFR开始收集数据、停止收集数据,或者保存所收集的数据,对应的子命令分别为JFR.startJFR.stop以及JFR.dump

        1
        $ jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
      • 上述命令运行过后,目标进程中的JFR已经开始收集数据。此时,我们可以通过下述命令来导出己经收集到的数据:

        1
        $ jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr
      • 最后,我们可以通过下述命令关闭目标进程中的JFR:

        1
        $ jcmd <PID> JFR.stop name=SomeLabel
    3. 方式3:JMC的JFR插件

      img

      • 具体使用:

        1. 启动飞行记录仪

          img

        2. 启动飞行记录

          img

        3. 正式启动

          img

          img

          img

  • Java Flight Recorder 取样分析

    • 要采用取样,必须先添加参数:

      • -XX: +UnlockCommercialFeatures
      • -XX: +Flight Recorder
    • 否则:

      image-20210718170905047

    • 取样时间默认1分钟,可自行按需调整,事件设置选为profiling,然后可以设置取样profile哪些信息,比如:

      • 加上对象数量的统计:Java Virtual Machine -> GC -> Detailed -> Object Count/Object Count after GC
      • 方法调用采样的间隔从10ms改为1ms(但不能低于 1ms,否则会影响性能了):Java Virtual Machine -> Profiling -> Method Profiling Sample/Method Sampling Information
      • Socket与File采样,10ms 太久,但即使改为1ms也未必能抓住什么,可以干脆取消掉:Java Application -> File Read/FileWrite/Socket Read/Socket Write

      image-20210718171442960

    • 然后就开始Profile,到时间后Profile 结束,会自动把记录下载回来,在JMC中展示。

      image-20210718171510051

    • 从展示信息中,我们大致可以读到内存和CPU信息、代码、线程和IO等比较重要的信息展示。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* -XX:+PrintFlagsFinal -Xms600m -Xmx600m
* -XX:SurvivorRatio=8
* 默认情况下,新生代占 1/3 : 200m,老年代占2/3 : 400m
* 其中,Eden默认占新生代的8/10 : 160m ,Survivor0,Survivor1各占新生代的1/10 : 20m
*/
public class OOMTest {
public static void main(String[] args) {
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(5);
} catch (InterruptedException e) {
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(100 * 50)));
}
}
}
class Picture{
private byte[] pixels;
public Picture(int length) {
this.pixels = new byte[length];
}
public byte[] getPixels() {
return pixels;
}
public void setPixels(byte[] pixels) {
this.pixels = pixels;
}
}

结果:

  1. 一般信息

    img

  2. 内存

    img

  3. 代码

    img

  4. 线程

    img

  5. I/O

    img

  6. 系统

    img

  7. 事件

    img

8、其他工具

1、Flame Graphs(火焰图)

在追求极致性能的场景下,了解你的程序运行过程中cpu在干什么很重要,火焰图就是一种非常直观的展示cpu在程序整个生命周期过程中时间分配的工具。

火焰图对于现代的程序员不应该陌生,这个工具可以非常直观的显示出调用栈中的CPU消耗瓶颈。

网上的关于java火焰图的讲解大部分来自于Brendan Gregg的博客

image-20210718172030880

火焰图简单通过x轴横条宽度来度量时间指标y轴代表线程栈的层次

2、Tprofiler
  • 案例:
    • 使用JDK自身提供的工具进行JVM调优可以将TPS由2.5提升到20(提升了7倍),并准确定位系统瓶颈
    • 系统的瓶颈有:应用里静态对象不是很多、有大量的业务进程在频繁创建一些生命周期很长的临时对象,代码里有问题
    • 那么,如何在海量业务代码里边准确定位这些性能代码?这里使用阿里开源工具Tprofiler来定位这些性能代码,成功解决掉GC过于频繁的性能瓶颈,并最终在上次优化的基础上将TPS在提升了4倍,即提升到100。
      • TProfiler配置部署、远程操作、日志阅读都不太复杂,操作还是很简单的。但是其却是能够起到一针见血、立竿见影的效果,帮我们解决了GC过于频繁的性能瓶颈。
      • TProfiler最重要的特性就是能够统计出你指定时间段内JVM 的top method,这些top method极有可能就是造成你JVM 性能瓶颈的元凶。这是其他大多数JVM调优工具所不具备的,包括JRockit Mission Control。JRokit 首席开发者Marcus Hirt在其私人博客《Low Overhead Method Profiling with Java Mission Control》下的评论中曾明确指出JRMC并不支持TOP方法的统计。
      • TProfiler的下载
3、Btrace
  • Java运行时追踪工具
  • 常见的动态追踪工具有BTrace、HouseMD (该项目已经停止开发)、Greys-Anatomy (国人开发,个人开发者)、Byteman (JBoss出品),注意Java运行时追踪工具并不限于这几种,但是这几个是相对比较常用的。
  • BTrace是SUN Kenai云计算开发平台下的一个开源项目,旨在为java提供安全可靠的动态跟踪分析工具。先看一下BTrace的官方定义:
    • BTrace is a safe, dynamic tracing tool for the Java platform. BTrace can be used to dynamically trace a running Java program (similar to DTrace for OpenSolaris applications and OS). BTrace dynamically instruments the classes of the target application to inject tracing code (“bytecode tracing”)。
  • 简洁明了,大意是一个Java平台的安全的动态追踪工具。可以用来动态地追踪一个运行的Java程序。BTrace动态调整目标应用程序的类以注入跟踪代码(“字节码跟踪”)。
4、YourKit
5、JProbe
6、Spring Insight

9、学习建议

Visual VM -> Arthus -> Jproflier(公司有能购买商业版) -> MAT

10、补充1:再谈内存泄露

1、内存泄露的理解与分析
1、何为内存泄漏( memory leak)

image-20210427230217504

可达性分析算法来判断对象是否是不再使用的对象,本质都是判断一个对象是否还被引用。那么对于这种情况下,由于代码的实现不同就会出现很多种内存泄漏问题(让JVM误以为此对象还在引用中,无法回收,造成内存泄漏)。

  • 是否还被使用?是
  • 是否还被需要?否
2、内存泄漏( memory leak) 的理解

严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏

但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。

image-20210717171640356

对象X引用对象Y,X的生命周期比Y的生命周期长;那么当Y生命周期结束的时候,X依然引用着Y,这时候,垃圾回收期是不会回收对象Y的;如果对象X还引用着生命周期比较短的A、B、C,对象A又引用着对象a、b、c,这样就可能造成大量无用的对象不能被回收,进而占据了内存资源,造成内存泄漏,直到内存溢出。

3、内存泄漏与内存溢出的关系
  1. 内存泄漏(memory leak)

    申请了内存用完了不释放,比如一共有1024M的内存,分配了512M 的内存一直不回收,那么可以用的内存只有512M了, 仿佛泄露掉了一部分;通俗点讲的话, 内存泄漏就是 [占着茅坑不拉shi] 。

  2. 内存溢出(out of memory)

    申请内存时,没有足够的内存可以使用;通俗一点儿讲,一个厕所就三个坑,有两个站着茅坑不走的(内存泄漏),剩下最后一个坑,厕所表示接待压力很大,这时候一下子来了两个人,坑位(内存)就不够了,内存泄漏变成内存溢出了。

    可见,内存泄漏和内存溢出的关系:内存泄漏的增多,最终会导致内存溢出。

4、泄漏的分类
  • 经常发生:发生内存泄露的代码会被多次执行,每次执行,泄露一块内存;(坚决杜绝)
  • 偶然发生:在某些特定情况下才会发生;
  • 一次性:发生内存泄露的方法只会执行一次;
  • 隐式泄漏:一直占着内存不释放,直到执行结束;严格的说这个不算内存泄漏,因为最终释放掉了,但是如果执行时间特别长,也可能会导致内存耗尽。
2、Java中内存泄露的8种情况
  • 静态集合类

    • 静态集合类,如HashMap、LinkedList等等。如果这些容器为静态的,那么它们的生命周期与JVM程序一致,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏。简单而言,长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用,但是因为长生命周期对象持有它的引用而导致不能被回收。

    • 代码

      1
      2
      3
      4
      5
      6
      7
      public class MemoryLeak {
      static List list = new ArrayList();
      public void oomTests() {
      Object obj = new Object();
      list.add(obj);
      }
      }
  • 单例模式

    • 单例模式,和静态集合导致内存泄露的原因类似,因为单例的静态特性,它的生命周期和JVM的生命周期一样长,所以如果单例对象如果持有外部对象的引用,那么这个外部对象也不会被回收,那么就会造成内存泄漏。
  • 内部类持有外部类

    • 内部类持有外部类,如果个外部类的实例对象的方法返回了一个内部类的实例对象。这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。
  • 各种连接,如数据库连接、网络连接和IO连接等

    • 各种连接,如数据库连接、网络连接和IO连接等。在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用close方法来释放与数据库的连接。只有连接被关闭后,垃圾回收器才会回收对应的对象。否则,如果在访问数据库的过程中,对Connection、 Statement或ResultSet 不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

    • 代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      public static void main(String[] args) {
      try {
      Connection conn = null;
      Class.forName ("com.mysq1.jdbc.Driver");
      conn = DriverManager . getConnection("url", 11);
      Statement stmt = conn. createStatement();
      ResultSet rs = stmt.executeQuery("....");
      } catch (Exception e) {
      //异常日志
      } finally {
      //1.关闭结果集Statement
      // 2.关闭声明的对象ResultSet
      // 3.关闭连接Connection
      }
  • 变量不合理的作用域

    • 变量不合理的作用域。一般而言,一个变量的定义的作用范围大于其使用范围,很有可能会造成内存泄漏。另一方面,如果没有及时地把对象设置为null,很有可能导致内存泄漏的发生

    • 伪代码:

      1
      2
      3
      4
      5
      6
      7
      public class UsingRandom {
      private String msg;
      public void receiveMsg(){
      readFromNet();//从网络中接受数据保存到msg中
      saveDB();//把msg保存到数据库中
      }
      }
    • 如上面这个伪代码,通过readFromNet方法把接受的消息保存在变量msg中,然后调用saveDB方法把msg的内容保存到数据库中,此时msg已经就没用了,由于msg的生命周期与对象的生命周期相同,此时msg还不能回收,因此造成了内存泄漏。

    • 实际上这个msg变量可以放在receiveMsg方法内部, 当方法使用完,那么msg的生命周期也就结束,此时就可以回收了。还有一种方法,在使用完msg后,把msg设置为null,这样垃圾回收器也会回收msg的内存空间。

    • 解决方法1:

      1
      2
      3
      4
      5
      6
      7
      public class UsingRandom {
      public void receiveMsg(){
      private String msg;
      msg = readFromNet();//从网络中接受数据保存到msg中
      saveDB();//把msg保存到数据库中
      }
      }
    • 解决方法2:

      1
      2
      3
      4
      5
      6
      7
      8
      public class UsingRandom {
      private String msg;
      public void receiveMsg(){
      readFromNet();//从网络中接受数据保存到msg中
      saveDB();//把msg保存到数据库中
      msg = null;
      }
      }
  • 改变哈希值

    • 改变哈希值,当一个对象被存储进HashSet集合中以后,就不能修改这个对象中的那些参与计算哈希值的字段了

    • 否则,对象修改后的哈希值与最初存储进HashSet集合中时的哈希值就不同了,在这种情况下,即使在contains方法使用该对象的当前引用作为的参数去HashSet集合中检索对象,也将返回找不到对象的结果,这也会导致无法从HashSet集合中单独删除当前对象,造成内存泄漏。

    • 这也是String为什么被设置成了不可变类型,我们可以放心地把String存入HashSet,或者把String当做HashMap的key值;

    • 当我们想把自己定义的类保存到散列表的时候,需要保证对象的hashCode不可变。

    • 代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      public class ChangeHashCode {
      public static void main(String[] args) {
      HashSet set = new HashSet();
      Person p1 = new Person(1001, "AA");
      Person p2 = new Person(1002, "BB");

      set.add(p1);
      set.add(p2);

      p1.name = "CC";//导致了内存的泄漏
      set.remove(p1); //删除失败

      System.out.println(set);

      set.add(new Person(1001, "CC"));
      System.out.println(set);

      set.add(new Person(1001, "AA"));
      System.out.println(set);
      }
      }
      class Person {
      int id;
      String name;

      public Person(int id, String name) {
      this.id = id;
      this.name = name;
      }
      @Override
      public boolean equals(Object o) {
      if (this == o) return true;
      if (!(o instanceof Person)) return false;

      Person person = (Person) o;

      if (id != person.id) return false;
      return name != null ? name.equals(person.name) : person.name == null;
      }
      @Override
      public int hashCode() {
      int result = id;
      result = 31 * result + (name != null ? name.hashCode() : 0);
      return result;
      }
      @Override
      public String toString() {
      return "Person{" +
      "id=" + id +
      ", name='" + name + '\'' +
      '}';
      }
      }
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      public class ChangeHashCode1 {
      public static void main(String[] args) {
      HashSet<Point> hs = new HashSet<Point>();
      Point cc = new Point();
      cc.setX(10);//hashCode = 41
      hs.add(cc);

      cc.setX(20);//hashCode = 51 此行为导致了内存的泄漏

      System.out.println("hs.remove = " + hs.remove(cc));//false
      hs.add(cc);
      System.out.println("hs.size = " + hs.size());//size = 2

      System.out.println(hs);
      }
      }
      class Point {
      int x;

      public int getX() {
      return x;
      }
      public void setX(int x) {
      this.x = x;
      }
      @Override
      public int hashCode() {
      final int prime = 31;
      int result = 1;
      result = prime * result + x;
      return result;
      }
      @Override
      public boolean equals(Object obj) {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass() != obj.getClass()) return false;
      Point other = (Point) obj;
      if (x != other.x) return false;
      return true;
      }
      @Override
      public String toString() {
      return "Point{" +
      "x=" + x +
      '}';
      }
      }
  • 缓存泄露

    • 内存泄漏的另一个常见来源是缓存,一旦你把对象引用放入到缓存中,他就很容易遗忘。比如:之前项目在一次上线的时候,应用启动奇慢直到夯死,就是因为代码中会加载一个表中的数据到缓存(内存)中,测试环境只有几百条数据,但是生产环境有几百万的数据。

    • 对于这个问题,可以使用WeakHashMap代表缓存,此种Map的特点是,当除了自身有对key的引用外,此key没有其他引用,那么此map会自动丢弃此值

    • 代码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      public class MapTest {
      static Map wMap = new WeakHashMap();
      static Map map = new HashMap();

      public static void main(String[] args) {
      init();
      testWeakHashMap();
      testHashMap();
      }

      public static void init() {
      String ref1 = new String("obejct1");
      String ref2 = new String("obejct2");
      String ref3 = new String("obejct3");
      String ref4 = new String("obejct4");
      wMap.put(ref1, "cacheObject1");
      wMap.put(ref2, "cacheObject2");
      map.put(ref3, "cacheObject3");
      map.put(ref4, "cacheObject4");
      System.out.println("String引用ref1,ref2,ref3,ref4 消失");
      }

      public static void testWeakHashMap() {
      System.out.println("WeakHashMap GC之前");
      for (Object o : wMap.entrySet()) {
      System.out.println(o);
      }
      try {
      System.gc();
      TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("WeakHashMap GC之后");
      for (Object o : wMap.entrySet()) {
      System.out.println(o);
      }
      }

      public static void testHashMap() {
      System.out.println("HashMap GC之前");
      for (Object o : map.entrySet()) {
      System.out.println(o);
      }
      try {
      System.gc();
      TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException e) {
      e.printStackTrace();
      }
      System.out.println("HashMap GC之后");
      for (Object o : map.entrySet()) {
      System.out.println(o);
      }
      }
      }
    • 结果:

      • String引用ref1,ref2,ref3,ref4 消失
      • WeakHashMap GC之前
      • obejct2=cacheObject2
      • obejct1=cacheObject1
      • WeakHashMap GC之后
      • HashMap GC之前
      • obejct4=cacheObject4
      • obejct3=cacheObject3
      • Disconnected from the target VM, address: ‘127.0.0.1:51628’, transport: ‘socket’
      • HashMap GC之后
      • obejct4=cacheObject4
      • obejct3=cacheObject3
    • 分析:

      image-20210717180541120

      • 上面代码和图示主演演示WeakHashMap如何自动释放缓存对象,当init函数执行完成后,局部变量字符串引用weakd1、weakd2、d1、d2都会消失,此时只有静态map中保存中对字符串对象的引用,可以看到,调用gc之后,HashMap的没有被回收,而WeakHashMap里面的缓存被回收了。
  • 监听器和回调

    • 内存泄漏另一个常见来源是监听器和其他回调,如果客户端在你实现的API中注册回调,却没有显示的取消,那么就会积聚。
    • 需要确保回调立即被当作垃圾回收的最佳方法是只保存它的弱引用,例如将他们保存成为WeakHashMap中的键。
3、内存泄露案例分析
1、案例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) { //入栈
ensureCapacity();
elements[size++] = e;
}
//存在内存泄漏
public Object pop() { //出栈
if (size == 0)
throw new EmptyStackException();
return elements[--size];//只是将指针下移,没有回收内存
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
2、分析

上述程序并没有明显的错误,但是这段程序有一个内存泄漏,随着GC活动的增加,或者内存占用的不断增加,程序性能的降低就会表现出来,严重时可导致内存泄漏,但是这种失败情况相对较少。

代码的主要问题在pop函数, 下面通过这张图示展现

假设这个栈一直增长,增长后如下图所示:

image-20210717181048650

当进行大量的pop操作时,由于引用未进行置空,gc是不会释放的,如下图所示:

image-20210717181439315

从上图中看以看出,如果栈先增长,在收缩,那么从栈中弹出的对象将不会被当作垃圾回收,即使程序不再使用栈中的这些队象,他们也不会回收,因为栈中仍然保存这对象的引用,俗称过期引用,这个内存泄露很隐蔽。

3、解决办法

将代码中的pop()方法变成如下方法:

1
2
3
4
5
6
7
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}

一旦引用过期,清空这些引用,将引用置空。

4、案例代码(与移动端的开发有关)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestActivity extends Activity {
private static final object key = new 0bject();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
new Thread(){//匿名线程
public void run() {
synchronized (key) {
try {
key.wait();
} catch (InterruptedException e) {
e.printStackTrace( );
}
}
}
}.start();
}
}
5、分析

image-20210717182307112

image-20210717182357403

内部类持有外部类:当GC要回收TestActivity的时候,发现内部类(匿名线程)内部持有了外部类(key对象),不能将TestActivity顺利回收,导致了内存泄露。

6、解决方法
  1. 使用线程时,一定要确保线程在周期性对象(如Activity) 销毁时能正常结束, 如能正常结束,但是Activity销毁后还需执行一段时间,也可能造成泄露,此时可采用WeakReference方法来解决,另外在使用Handler的时候,如存在Delay操作,也可以采用WeakReference;
  2. 使用Handler + HandlerThread时, 记住在周期性对象销毁时调用looper.quit()方法;

11、补充2:支持使用OQL语言查询对象信息

1、介绍

MAT支持一种类似于SQL的查询语言OQL (Object Query Language) 。OQL使用类SQL语法,可以在堆中进行对象的查找和筛选。

2、在Eclipse MAT中如何用

img

3、例子
  1. select * from java.util.ArrayList(列出所有的ArrayList对象信息)
  2. select v.elementData from java.util.ArrayList v(注意:elementData代表ArrayList底层的数组,结果最终以数组形式将结果呈现出来)
  3. select objects v.elementData from java.util.ArrayList v(注意:elementData代表ArrayList底层的数组,objects代表对象类型,所以最终以对象形式将结果呈现出来,同时展示出来的还有浅堆、深堆)
  4. select as retained set * from com.atguigu.mat.Student(得到对象的保留级)
  5. select * from 0x6cd57c828(0x6cd57c828是Student类的地址值)
  6. select * from char[] s where s.@length > 10(char型数组长度大于10的数组)
  7. select * from java.lang.String s where s.value != null(字符串值不为空的字符串信息)
  8. select toString(f.path.value) from java.io.File f(列出文件的路径值)
  9. select v.elementData.@length from java.util.ArrayList v(列出Arraylist对象中ArrayList中的数组长度)
4、SELECT子句

在MAT中,Select子句的格式与SQL基本一致,用于指定要显示的列。Select子句中可以使用*,查看结果对象的引用实例(相当于outgoing references) 。

SELECT * FROM java.util.Vector v

使用”OBJECTS“关键字,可以将返回结果集中的项以对象的形式显示。

  • SELECT objects v.elementData FROM java.util.Vector v
  • SELECT OBJECTS s.value FROM java.lang.String s

在Select子句中,使用”AS RETAINED SET“关键字可以得到所得对象的保留集。

  • SELECT AS RETAINED SET * FROM com.atguigu.mat.Student

“DISTINCT”关键字用于在结果集中去除重复对象。

  • SELECT DISTINCT OBJECTS classof(s) FROM java.lang.String s
5、FROM子句

From子句用于指定查询范围,它可以指定类名、正则表达式或者对象地址。

SELECT * FROM java.lang.StrIng s

下例使用正则表达式,限定搜索范围,输出所有com. atguigu包下所有类的实例

  • SELECT * FROM “com\.atguigu\..*”

也可以直接使用类的地址进行搜索。使用类的地址的好处是可以区分被不同ClassLoader加载的同一种类型

  • select * from 0x37a0b4d
6、WHERE子句

Where子句用于指定OQL的查询条件。OQL查询将只返回满足Where子句指定条件的对象。Where子句的格式与传统SQL极为相似。

下例返回长度大于10的char数组。

  • SELECT * FROM char[] s WHERE s.@length>10

下例返回包含”java”子字符串的所有字符串,使用”LIKE”操作符,”LIKE”操作符的操作参数为正则表达式。

  • SELECT * FROM java.lang.String s WHERE toString(s) LIKE “. *java. *”

下例返回所有value域不为null的字符串,使用”=”操作符。

  • SELECT * FROM java.lang.String s where s. value!=null

Where子句支持多个条件的AND、OR运算。下例返回数组长度大于15,并且深堆大于1000字节的所有Vector对象。

  • SELECT * FROM java.util.Vector v WHERE v.elementData.@length>15 AND v. @retainedHeapSize>1000
7、内置对象与方法

OQL中可以访问堆内对象的属性,也可以访问堆内代理对象的属性。访问堆内对象的属性时,格式如下:

  • [ . ] . .
  • 其中alias为对象名称。

访问java.io.File对象的path属性,并进一步访问path的value属性:

  • SELECT toString(f.path.value) FROM java.io.File f

下例显示了String对象的内容、objectid和objectAddress。

  • SELECT s.toString(), s.@objectId, s.@objectAddress FROM java.lang.String s

下例显示java.util.Vector内部数组的长度。

  • SELECT v.elementData.@length FROM java.util.Vector v

下例显示了所有的java.util.Vector对象及其子类型

  • select * from INSTANCEOF java.util.Vector

4、JVM运行时参数

1、JVM参数选项

参数来源

1、类型一:标准参数选项
1、特点
  • 比较稳定,后续版本基本不会变化
  • -开头
2、各种选项

直接在DOS窗口中运行java或者java -help可以看到所有的标准选项

image-20210718175015752

image-20210718175046637

3、补充内容:-server与-client

Hotspot JVM有 两种模式,分别是server(C2编译器)和client(C1编译器),分别通过-server和-client模式设置

  1. 在32位Windows系统上,默认使用Client类型的JVM。要想使用Server模式,则机器配置至少有2个以上的CPU和2G以上的物理内存。client模式适用于对内存要求较小的桌面应用程序,默认使用Serial串行垃圾收集器

  2. 64位机器上只支持server模式的JVM,适用于需要大内存的应用程序,默认使用并行垃圾收集器

  3. 关于server和client的官网介绍

对于以上第2点,我们可以打开DOS窗口,输入java -version就可以看到64位机器上用的server模式,如下所示:

img

2、类型二:-X参数选项
1、特点
  • 非标准化参数
  • 功能还是比较稳定的。但官方说后续版本可能会变更
  • -X开头
2、各种选项

直接在DOS窗口中运行java -X命令可以看到所有的X选项

image-20210718175909651

其中

  • -Xmixed 混合模式执行 (默认)
  • -Xint 仅解释模式执行
  • -Xcomp 仅采用即时编译器模式
3、JVM的JIT编译模式相关的选项
  • -Xint

    • 只使用解释器:所有字节码都被解释执行,这个模式的速度是很慢的
  • -Xcomp

    • 只使用编译器:所有字节码第一次使用就被编译成本地代码,然后在执行
  • -Xmixed(默认)

    • 混合模式:这是默认模式,刚开始的时候使用解释器慢慢解释执行,后来让JIT即时编译器根据程序运行的情况,有选择地将某些热点代码提前编译并缓存在本地,在执行的时候效率就非常高了。

    • 默认使用的就是这种模式,证明如下:

      img

4、特别地:-Xmx -Xms -Xss属于XX参数?
  • 单位分别是:k/K、m/M、g/G

  • 设置:-Xmx、-Xms最好设置成一样的值,避免扩容带来的损耗

    • -Xms

      • 设置初始Java堆大小,等价于-XX:InitialHeapSize

      • 查看该参数值的时候,应该使用InitialHeapSize,例如jinfo flag InitialHeapSize 进程id

      • 等价证明:

        img

        img

    • -Xmx

      • 设置最大Java堆大小,等价于-XX:MaxHeapSize

      • 查看该参数值的时候,应该使用MaxHeapSize,例如jinfo flag InitialHeapSize 进程id

      • 等价证明:

        image-20210718181620379

    • -Xss

      • 设置Java线程堆栈大小,等价于-XX:ThreadStackSize
      • 查看该参数值的时候,应该使用ThreadStackSize,例如jinfo flag InitialHeapSize 进程id
3、类型三:-XX参数选项(重要)
1、特点
  • 非标准化参数
  • 使用的最多的参数类型
  • 这类选项属于实验性,不稳定
  • -XX开头
2、作用

用于开发和调试JVM

3、分类
  • Boolean类型格式
    • -XX:+
    • -XX:-
    • 举例:
      • -XX:+UseParallelGC:选择垃圾收集器为并行收集器
      • -XX:+UseG1GC:表示启用G1收集器
      • -XX:+UseAdaptiveSizePolicy:自动选择年轻代区大小和相应的Survivor区比例
    • 说明:因为有的指令默认是开启的,所以可以使用-关闭
  • 非Boolean类型格式(key-value类型)
    • 子类型1:数值型格式-XX:
    • 子类型2:非数值型格式-XX:=
      • 例如:
        • -XX:HeapDumpPath=/usr/local/heapdump.hprof:用来指定heap转存文件的存储路径。
4、特别地:-XX:+PrintFlagsFinal
  • 输出所有参数的名称和默认值
  • 默认不包括Diagnostic和Experimental的参数
  • 可以配合-XX:+UnlockDiagnosticVMOptions-XX:UnlockExperimentalVMOptions使用

2、添加JVM参数选项

1、Eclipse
  1. 在空白处单击右键,选择Run As,在选择Run Configurations……

    img

  2. 设置虚拟机参数

    img

2、IDEA
  1. Edit Configurations…

    img

  2. 设置虚拟机参数

    img

3、运行jar包
  • 这是在java -jar demo.jar中的java -jar之间添加了虚拟机配置信息
    • java -Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -jar demo.jar
4、通过Tomcat运行war包
  • Linux系统下可以在tomcat/bin/catalina.sh中添加类似如下配置:
    • JAVA_OPTS=”-Xms512M -Xmx1024M”
  • Windows系统下载catalina.bat中添加类似如下配置:
    • set “JAVA_OPTS=-Xms512M -Xmx1024M”
5、程序运行过程中

jinfo不仅可以查看运行时某一个Java虛拟机参数的实际取值,甚至可以在运行时修改部分参数,并使之立即生效。

但是,并非所有参数都支持动态修改。参数只有被标记为manageable的flag可以被实时修改。其实,这个修改能力是极其有限的。

#可以查看被标记为manageable的参数:java -XX:+PrintFlagsFinal -version | grep manageable

image-20210713001449847

  • 使用jinfo -flag = 设置非Boolean类型参数
  • 使用jinfo -flag [+|-] 设置Boolean类型参数

3、常用的JVM参数选项

1、打印设置的XX选项及值
  • -XX:+PrintCommandLineFlags:可以让程序运行前打印出用户手动设置或者JVM自动设置的XX选项

  • -XX:+PrintFlagsInitial

  • **-XX:+PrintFlagsFinal**:表示打印出XX选项在运行程序时生效的值

    • 如果值的前面加上了:=,说明该值不是初始值,该值可能被jvm自动改变了,也可能被我们设置的参数改变了,如下所示:

      img

    • 有一些被改变的值是项目在启动过程中,系统帮我们修改的

    • 注意区别:

      • -XX:+PrintFlagsFinal是打印出所有XX选项在运行程序时生效的值

      • jinfo -flag 参数名称 进程id**:查看某个java进程的具体**参数信息

        • 进程id可以通过jps命令查看具体操作如下:(其中3540代表进程id)

          image-20210713001051530

  • -XX:+PrintVMOptions:打印JVM的参数

2、堆、栈、方法区等内存大小设置
1、栈
  • -Xss128k
    • 设置每个线程的栈大小为128k
    • 等价于-XX:ThreadStackSize
2、堆内存
  • -Xms3550m
    • 等价于-XX:InitialHeapSize,设置JVM初始堆内存为3500M
  • -Xmx3550m
    • 等价于-XX:MaxHeapSize,设置JVM最大堆内存为3500M
  • -Xmn2g
    • 设置年轻代大小为2G,即等价于-XX:NewSize=2g -XX:MaxNewSize=2g,也就是设置年轻代初始值和年轻代最大值都是2G
    • 官方推荐配置为整个堆大小的3/8
  • -XX:NewSize=1024m
    • 设置年轻代初始值为1024M
  • -XX:MaxNewSize=1024m
    • 设置年轻代最大值为1024M
  • -XX:SurvivorRatio=8
    • 设置年轻代中Eden区与一个Survivor区的比值,默认为8
    • 只有显示使用Eden区和Survivor区的比例,才会让比例生效,否则比例都会自动设置,至于其中的原因,请看下面的-XX:+UseAdaptiveSizePolicy中的解释,最后推荐使用默认打开的-XX:+UseAdaptiveSizePolicy设置,并且不显示设置-XX:SurvivorRatio
  • -XX:+UseAdaptiveSizePolicy
    • 自动选择各区大小比例,默认开启
    • 分析
      • 默认开启,将会导致Eden区和Survivor区的比例自动分配,因此也会引起我们默认值-XX:SurvivorRatio=8失效,所以真实比例可能不是8,比如可能是6等
    • 如何设置Eden区和Survivor区的比例:-XX:SurvivorRatio=8
      1. 显示使用显示使用Eden区和Survivor区的比例,那就使用我自己的
      2. 没有显示使用Eden区和Survivor区的比例,无论打开或者关闭-XX:+UseAdaptiveSizePolicy,都会自动设置Eden区和Survivor区的比例
    • 结论:
      • 只有显示使用Eden区和Survivor区的比例,才会让比例生效,否则比例都会自动设置,最后推荐使用默认打开的-XX:+UseAdaptiveSizePolicy设置,并且不显示设置-XX:SurvivorRatio
  • -XX:NewRatio=2
    • 设置老年代与年轻代(包括1个Eden区和2个Survivor区)的比值,默认为2
    • 根据实际情况进行设置,主要根据对象生命周期来进行分配,如果对象生命周期很长,那么让老年代大一点,否则让新生代大一点
  • -XX:PretenureSizeThreadshold=1024
    • 设置让大于此阈值的对象直接分配在老年代,单位为字节
    • 只对Serial、ParNew收集器有效
    • 不好控制
  • -XX:MaxTenuringThreshold=15
    • 默认值为15
    • 新生代每次MinorGC后,还存活的对象年龄+1,当对象的年龄大于设置的这个值时就进入老年代
  • -XX:+PrintTenuringDistribution
    • 让JVM在每次MinorGC后打印出当前使用的Survivor中对象的年龄分布
  • -XX:TargetSurvivorRatio
    • 表示MinorGC结束后Survivor区域中占用空间的期望比例
3、方法区
  • 永久代
    • -XX:PermSize=256m
      • 设置永久代初始值为256M
    • -XX:MaxPermSize=256m
      • 设置永久代最大值为256M
  • 元空间
    • -XX:MetaspaceSize
      • 初始空间大小
    • -XX:MaxMetaspaceSize
      • 最大空间,默认没有限制
    • -XX:+UseCompressedOops
      • 使用压缩对象指针
    • -XX:+UseCompressedClassPointers
      • 使用压缩类指针
    • -XX:CompressedClassSpaceSize
      • 设置Klass Metaspace的大小,默认1G
4、直接内存
  • -XX:MaxDirectMemorySize
    • 指定DirectMemory容量,若未指定,则默认与Java堆最大值一样
3、OutOfMemory相关的选项
  • -XX:+HeapDumpOnOutMemoryError

    • 表示在内存出现OOM的时候,生成Heap转储文件,以便后续分析
    • -XX:+HeapDumpBeforeFullGC-XX:+HeapDumpOnOutMemoryError只能设置1个
  • -XX:+HeapDumpBeforeFullGC

    • 表示在出现FullGC之前,生成Heap转储文件,以便后续分析
    • -XX:+HeapDumpBeforeFullGC-XX:+HeapDumpOnOutMemoryError只能设置1个
    • 请注意FullGC可能出现多次,那么dump文件也会生成多个,而OOM只能有一次,所以-XX:+HeapDumpOnOutMemoryError生成的dump文件只有一个
  • -XX:HeapDumpPath=<path>

    • 指定heap转存文件的存储路径,如果不指定,就会将dump文件放在当前目录中
  • -XX:OnOutOfMemoryError

    • 指定一个可行性程序或者脚本的路径,当发生OOM的时候,去执行这个脚本

    • 对OnOutOfMemoryError的运维处理:

      • 以部署在linux系统/opt/Server目录下的Server.jar为例:

        1. 在run. sh启动脚本中添加jvm参数:-XX:OnOutOfMemoryError=/opt/Server/restart.sh

        2. restart.sh脚本:

          • linux环境:

            1
            2
            3
            4
            #!/bin/bash
            pid=$(ps -eflgrep Server.jar|awk '{if($8=="java") {print $2}}')
            kill -9 $pid
            cd /opt/Server/;sh run.sh
          • Windows环境:

            1
            2
            3
            4
            echo off
            wmic process where Name='java.exe' delete
            cd D:\Server
            start run.bat
4、垃圾收集器相关选项

7款经典收集器与垃圾分代之间的关系:

image-20210429024547757

垃圾收集器的组合关系:

image-20210429023609239

  1. 两个收集器间有连线,表明它们可以搭配使用:

    Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  2. 其中Serial Old作为CMS出现”Concurrent Mode Failure” 失败的后备预案。

  3. (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214) ,即:移除。

  4. (绿色虚线)JDK 14中:弃用Parallel Scavenge和Serial Old GC组合(JEP366)

  5. (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)

1、查看默认的垃圾回收器
  • -XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令: jinfo - flag 相关垃圾回收器参数进程ID

以上两种方式都可以查看默认使用的垃圾回收器,第一种方式更加准备,但是需要程序的支持;第二种方式需要去尝试,如果使用了,返回的值中有+号(使用),否则就是-号(没使用)。

2、Serial回收器
  • Serial收集器作为HotSpot中Client模式下的默认新生代垃圾收集器。
  • Serial Old是运行在Client模式下默认的老年代的垃圾回收器。
  • -XX:+UseSerialGC
    • 指定年轻代和老年代都使用串行收集器。等价于新生代用Serial GC,且老年代用Serial Old GC。 可以获得最高的单线程收集效率。
3、Parnew回收器
  • -XX: +UseParNewGC
    • 手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
  • -XX:ParallelGCThreads
    • 设置年轻代并行收集器的线程数。一般地, 最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
    • 在默认情况下,当CPU数量小于8个, ParallelGCThreads 的值等于CPU数量。
    • 当CPU数量大于8个,ParallelGCThreads的值等于3 + [5 * CPU_Count] / 8]

注意:根据下图可知,该回收器最终将会没有搭档,那就相当于被遗弃了(JDK14以后)

image-20210429023609239

4、Parallel回收器
  • -XX:+UseParallelGC
    • 手动指定年轻代使用Parallel并行收集器执行内存回收任务。
  • -XX: +UseParallelOldGC
    • 手动指定 =老年代都是使用并行回收收集器。
      • 分别适用于新生代和老年代。默认jdk8是开启的。
      • 上面两个参数,默认开启一个, 另一个也会被开启。(互相激活)
  • -XX:ParallelGCThreads
    • 设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。
    • 在默认情况下,当CPU 数量小于8个,ParallelGCThreads 的值等于CPU数量
    • 当CPU数量大于8个,ParallelGCThreads的值等于3 + [5 * CPU_Count] / 8]
  • -XX:MaxGCPauseMillis
    • 设置垃圾收集器最大停顿时间(即STW的时间)。单位是毫秒。
    • 为了尽可能地把停顿时间控制在MaxGCPauseMills以内,收集器在工作时会调整Java堆大小或者其他一些参数。
    • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。
    • 该参数使用需谨慎。
  • -XX:GCTimeRatio
    • 垃圾收集时间占总时间的比例(= 1 / (N + 1))。用于衡量吞吐量的大小。
    • 取值范围(0,100) 。默认值99,也就是垃圾回收时间不超过1%。
    • 与前一个-XX:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。
  • -XX: +UseAdaptiveSizePolicy
    • 设置Parallel Scavenge收集器具有自适应调节策略
    • 在这种模式下,年轻代的大小、Eden 和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
    • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio) 和停顿时间(MaxGCPauseMills) ,让虚拟机自已完成调优工作。

注意:

  1. Parallel回收器主打吞吐量,而CMS和G1主打低延迟如果主打吞吐量,那么就不应该限制最大停顿时间,所以-XX:MaxGCPauseMills不应该设置
  2. -XX:MaxGCPauseMills中的调整堆大小通过默认开启的-XX:+UseAdaptiveSizePolicy来实现
  3. -XX:GCTimeRatio用来衡量吞吐量,并且和-XX:MaxGCPauseMills矛盾,因此不会同时使用
5、CMS回收器
  • -XX:+UseConcMarkSweepGC
    • 手动指定使用CMS收集器执行内存回收任务。
    • 开启该参数后会自动将-XX:+UseParNewGC打开。
    • 即:ParNew(Young区用)+CMS(Old区用)+Serial Old的组合。
  • -XX:CMS1nitiatingOccupanyFraction
    • 设置堆内存使用率的阅值,一旦达到该阈值,便开始进行回收。
    • JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。**JDK6及以上版本默认值为92%**。
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阅值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Full GC的执行次数
  • -XX:+UseCMSCompactAtFullCollection
    • 用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时
      间变得更长了。
  • -XX:CMSFullGCsBeforeCompaction
    • 设置在执行多少次Full GC后对内存空间进行压缩整理。
  • -XX:ParallelCMSThreads
    • 设置CMS的线程数量。
    • CMS默认启动的线程数是**(ParallelGCThreads + 3) / 4**,ParallelGCThreads 是年轻代并行收集器的线程数。当CPU 资源比较紧涨时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

-XX:ParallelCMSThreads和ParallelGCThreads有关系,ParallelGCThreads在上面Parnew回收器中有提到

另外,CMS收集器还有如下常用参数:

  • -XX:ConcGCThreads
    • 设置并发垃圾收集的线程数,默认该值是基于ParallelGCThreads计算出来的;
  • -XX:+UseCMSInitiatingOccupancyOnly
    • 是否动态可调,用这个参数可以使CMS直按CMSInitiatingOccupancyFraction设定的值启动
  • -XX:+CMSScavengeBeforeRemark
    • 强制hotspot 虚拟机在cms remark阶段之前做一次minor gc,用于提高remark阶段的速度
  • -XX:+CMSClassUnloadingEnable
    • 如果有的话,启用回收Perm区(JDK8之前)
  • -XX:+CMSParallelInitialEnabled
    • 用于开启CMS initial-mark阶段采用多线程的方式进行标记,用于提高标记速度,在Java8开始已经默认开启;
  • -XX:+CMSParallelRemarkEnbled
    • 用户开启CMS remark阶段采用多线程的方式进行重新标记。默认开启;
  • -XX:+ExplicitGCInvokesConcurrent-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
    • 这两个参数用户指定hotspot虚拟在执行System.gc()时使用CMS周期;
  • -XX:+CMSPrecleaningEnabled
    • 指定CMS是否需要进行Pre cleaning这个阶段

特别说明:

  • JDK9新特性:CMS被标记为Deprecate 了(JEP291)
    • 如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示
      CMS未来将会被废弃。
  • JDK14新特性:删除CMS垃圾回收器(JEP363)
    • 移除了CMS垃圾收集器,如果在JDK14中使用-XX:+UseConcMarkSweepGC的话JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认GC方式启动JVM
    • OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGc;support was removed in 14.0 and the VM will continue execution using the default collector.
6、G1回收器
  • -XX:+UseG1GC
    • 手动指定使用G1收集器执行内存回收任务。
  • -XX:G1HeapRegionSize
    • 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000
  • -XX: MaxGCPauseMillis
    • 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
    • -XX: ParallelGCThread
      • 设置STW时GC线程数的值。最多设置为8
  • -XX:ConcGCThreads
    • 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右
  • -XX:InitiatingHeapoccupancyPercent
    • 设置触发并发GC周期的Java堆占用率阙值。超过此值,就触发GC。默认值是45
  • -XX: G1NewSizePercent-XX:G1MaxNewSizePercent
    • 新生代占用整个堆内存的最小百分比(默认5%)、最大百分比( 默认60%)
  • -XX:G1ReservePercent=10
    • 保留内存区域,防止to space (Survivor中的to区)溢出

注意:

  • 如果使用G1垃圾收集器,不建议设置-Xmn-XX:NewRatio,毕竟可能影响G1的自动调节

Mixed GC调优参数:

  • 注意:
    • G1收集器主要涉及到Mixed GC,Mixed GC 会回收young区和部分old区
  • G1关于MixedGC调优常用参数:
    • -XX:InitiatingHeapOccupancyPercent
      • 设置堆占用率的百分比(0到100)达到这个数值的时候触发global concurrent marking (全局并发标记),默认为45%**。值为0表示间断进行全局并发标记**。
    • -XX:G1MixedGCLiveThresholdPercent
      • 设置Old区的region被回收时候的对象占比,**默认占用率为85%**。只有Old区的region中存活的对象占用达到了这个百分比才会在Mixed GC中被回收。
    • -XX:G1HeapWastePercent
      • 在global concurrent marking (全局并发标记)结束之后,可以知道所有的区有多少空间要被回收,在每次young GC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
    • -XX:G1MixedGCCountTarget
      • 一次global concurrent marking (全局并发标记)之后,最多执行Mixed GC的次数,默认是8
    • -XX:G1OldCSetRegionThresholdPercent
      • 设置Mixed GC收集周期中要收集的Old region数的 上限。默认值是Java堆的10%
7、怎么选择垃圾收集器
  • 优先调整堆的大小让JVM自适应完成。
  • 如果内存小于100M,使用串行收集器
  • 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
  • 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
  • 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器。官方推荐G1,性能高。现在互联网的项目,基本都是使用G1

特别说明:

  1. 没有最好的收集器,更没有万能的收集;
  2. 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器
5、GC日志相关选项
1、常用参数
  • -verbose:gc

    • 输出日志信息,默认输出的标准输出

    • 可以独立使用

      image-20210719003357660

  • -XX:+PrintGC

    • 等同于-verbose:gc表示打开简化的日志

    • 可以独立使用

      image-20210719003357660

  • **-XX:+PrintGCDetails**:

    • 在发生垃圾回收时打印内存回收详细的日志,并在进程退出时输出当前内存各区域的分配情况

    • 可以独立使用

      image-20210719003601948

  • **-XX:+PrintGCTimeStamps**:

    • 程序启动到GC发生的时间秒数

    • 不可以独立使用,需要配合-XX:+PrintGCDetails使用

      img

  • -XX:+PrintGCDateStamps

    • 输出GC发生时的时间戳(以日期的形式,例如:2013-05-04T21:53:59.234+0800)

    • 不可以独立使用,可以配合-XX:+PrintGCDetails使用

      img

  • -XX:+PrintHeapAtGC

    • 每一次GC前和GC后,都打印堆信息

    • 可以独立使用

    • 若配合**-XX:+PrintGCDetails**一起使用的话,可以将两个命令的结果结合在一起:每一次GC前和GC后,都打印堆信息+在进程退出时输出当前内存各区域的分配情况

      img

  • **-XIoggc:<file>**:

    • 把GC日志写入到一个文件中去,而不是打印到标准输出中

      img

2、其他参数
  • -XX:TraceClassLoading

    • 监控类的加载
  • -XX:PrintGCApplicationStoppedTime

    • 打印GC时线程的停顿时间

      image-20210719004842828

  • -XX:+PrintGCApplicationConcurrentTime

    • 垃圾收集之前打印出应用未中断的执行时间
  • -XX:+PrintReferenceGC

    • 记录回收了多少种不同引用类型的引用
  • -XX:+PrintTenuringDistribution

    • 让JVM在每次MinorGC后打印出当前使用的Survivor中对象的年龄分布
  • -XX:+UseGCLogFileRotation

    • 启用GC日志文件的自动转储
  • -XX:NumberOfGCLogFiles=1

    • GC日志文件的循环数目
  • -XX:GCLogFileSize=1M

    • 控制GC日志文件的大小
6、其他参数
  • -XX:+DisableExplicitGC
    • 禁用hotspot执行System.gc(),默认禁用
  • -XX:ReservedCodeCacheSize=<n>[g|m|k]-XX:InitialCodeCacheSize=<n>[g|m|k]
    • 指定代码缓存的大小
  • -XX:+UseCodeCacheFlushing
    • 使用该参数让jvm放弃一些被编译的代码,避免代码缓存被占满时JVM切换到interpreted-only的情况
  • -XX:+DoEscapeAnalysis
    • 开启逃逸分析
  • -XX:+UseBiasedLocking
    • 开启偏向锁
  • -XX:+UseLargePages
    • 开启使用大页面
  • -XX:+PrintTLAB
    • 打印TLAB的使用情况
  • -XX:TLABSize
    • 设置TLAB大小

4、通过Java代码获取JVM参数

Java提供了java.lang.management包用于监视和管理Java虚拟机和Java运行时中的其他组件,它允许本地和远程监控和管理运行的Java虛拟机。其中ManagementFactory这个类还是挺常用的。另外还有Runtime类也可以获取一些内存、CPU核数等相关的数据。

通过这些api可以监控我们的应用服务器的堆内存使用情况,设置一些阈值进行报警等处理。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
*
* 监控我们的应用服务器的堆内存使用情况,设置一些阈值进行报警等处理
*/
public class MemoryMonitor {
public static void main(String[] args) {
MemoryMXBean memorymbean = ManagementFactory.getMemoryMXBean();
MemoryUsage usage = memorymbean.getHeapMemoryUsage();
System.out.println("INIT HEAP: " + usage.getInit() / 1024 / 1024 + "m");
System.out.println("MAX HEAP: " + usage.getMax() / 1024 / 1024 + "m");
System.out.println("USE HEAP: " + usage.getUsed() / 1024 / 1024 + "m");
System.out.println("\nFull Information:");
System.out.println("Heap Memory Usage: " + memorymbean.getHeapMemoryUsage());
System.out.println("Non-Heap Memory Usage: " + memorymbean.getNonHeapMemoryUsage());

System.out.println("=======================通过java来获取相关系统状态============================ ");
System.out.println("当前堆内存大小totalMemory " + (int) Runtime.getRuntime().totalMemory() / 1024 / 1024 + "m");// 当前堆内存大小
System.out.println("空闲堆内存大小freeMemory " + (int) Runtime.getRuntime().freeMemory() / 1024 / 1024 + "m");// 空闲堆内存大小
System.out.println("最大可用总堆内存maxMemory " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "m");// 最大可用总堆内存大小

}
}

在上篇可以通过Runtime获取:

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HeapSpaceInitial {
public static void main(String[] args) {
//返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
//返间Java虚拟机试图使用的最大堆内存量
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

System.out.print1n("-Xms :" + initialMemory + "M");
System.out.println("-Xmx :" + maxMemory + "M");

System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
}
}

5、分析GC日志

1、GC日志参数

同上面第4、JVM运行时参数中的第5点、GC日志相关选项一致

2、GC日志格式

1、复习:GC分类

针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集(Partial GC):不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(Minor GC / Young GC):只是新生代(Eden / S0, S1)的垃圾收集
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
      • 目前,只有 CMS GC 会有单独收集老年代的行为。
      • 注意:
        • 在进行Major GC之前,系统会先进行一次Minor GC
        • 很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 java 堆和方法区的垃圾收集。

那些情况会触发Full GC呢?

  • 老年代空间不足
  • 方法区空间不足
  • 显式调用System.gc()
  • Minor GC进入老年代的数据的平均大小 大于 老年代的可用内存
  • 大对象直接进入老年代,而老年代的可用空间不足
2、GC日志分类
1、Minor GC

MinorGC(或 young GC 或 YGC)日志:

1
[GC (Allocation Failure) [PSYoungGen: 31744K->2192K (36864K) ] 31744K->2200K (121856K), 0.0139308 secs] [Times: user=0.05 sys=0.01, real=0.01 secs]

image-20210719011748465

image-20210719011823127

2、Full GC
1
[Full GC (Metadata GC Threshold) [PSYoungGen: 5104K->0K (132096K) ] [Par01dGen: 416K->5453K (50176K) ]5520K->5453K (182272K), [Metaspace: 20637K->20637K (1067008K) ], 0.0245883 secs] [Times: user=0.06 sys=0.00, real=0.02 secs]

image-20210719011929852

image-20210719012024393

3、GC日志结构剖析
1、透过日志看垃圾收集器
  • 使用Serial收集器新生代的名字是Default New Generation,因此显示的是”[DefNew
  • 使用ParNew收集器新生代的名字会变成”[ParNew“,意思是”Parallel New Generation’
  • 使用Parallel Scavenge收集器新生代的名字是”[PSYoungGen“,这里的JDK1.7使用的就是PSYoungGen
  • 使用Parallel Old Generation收集器老年代的名字是”[ParOldGen
  • 使用G1收集器的话,会显示为”garbage-first heap
2、透过日志看 GC 原因
  • Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了
  • Metadata GCThreshold:Metaspace 区不够用了
  • FErgonomics:JVM 自适应调整导致的 GC
  • System:调用了 System.gc()方法
3、透过日志看 GC 前后情况

通过图示,我们可以发现 GC 日志格式的规律一般都是:GC 前内存占用 -> GC 后内存占用(该区域内存总大小)

1
[PSYoungGen: 5986K->696K (8704K) ] 5986K->704K (9216K)
  • 中括号内:GC 回收前年轻代堆大小,回收后大小,(年轻代堆总大小)
  • 括号外:GC 回收前年轻代和老年代大小,回收后大小,(年轻代和老年代总大小)

注意:

  • Minor GC 堆内存总容量 = 9/10 年轻代 + 老年代。
    • 原因是 Survivor 区只计算 from 部分,而 JVM 默认年轻代中 Eden 区和 Survivor 区的比例关系,Eden:S0:S1=8:1:1。
4、透过日志看 GC 时间

GC 日志中有三个时间:usersysreal(结果采用四舍五入的形式)

  • user:进程执行用户态代码(核心之外)所使用的时间。这是执行此进程所使用的实际 CPU 时间,其他进程和此进程阻塞的时间并不包括在内。在垃圾收集的情况下,表示 GC 线程执行所使用的 CPU 总时间
  • sys:进程在内核态消耗的 CPU 时间,即在内核执行系统调用或等待系统事件所使用的 CPU 时间
  • real程序从开始到结束所用的时钟时间。这个时间包括其他进程使用的时间片和进程阻塞的时间(比如等待 I/O 完成)。对于并行 gc,这个数字应该接近(用户时间+系统时间)除以垃圾收集器使用的线程数

由于多核的原因,一般的 GC 事件中,real time 是小于 sys time + user time 的,因为一般是多个线程并发的去做 GC,所以 real time 是要小于 sys + user time 的。如果 real > sys + user 的话,则你的应用可能存在下列问题:IO 负载非常重或 CPU 不够用。

4、Minor GC日志解析

-XX:+PrintGCTimeStamps + -XX:+PrintGCDateStamps + -XX:+PrintGCDetails

1
2020-11-20T17:19:43.265-0800:0.822: [GC (ALLOCATION FAILURE) [PSYOUNGGEN:76800K->8433K(89600K)] 76800K -> 8449K(294400K), 0.0088371 SECS] [TIMES:USER=0.02 SYS=0.01, REAL=0.01 SECS]
  • 2020-11-20T17:19:43.265-0800
    • 日志打印时间日期格式如:2013-05-04T21:53:59.234+0800
  • 0.822
    • gc发生时,Java虛拟机启动以来经过的秒数
  • [GC (Allocation Failure)
    • 发生了一次垃圾回收,这是一次Minor GC。它不区分新生代GC还是老年代GC,括号里的内容是gc发生的原因,这里的Allocation Failure的原因是新生代中没有足够区域能够存放需要分配的数据而失败
    • 除此之外还有:
      • Metadata GCThreshold:Metaspace 区不够用了
      • FErgonomics:JVM 自适应调整导致的 GC
      • System:调用了 System.gc()方法
  • [PSYoungGen:76800K->8433K(89600K)]
    • PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
      • Serial收集器:Default New Generation显示DefNew
      • ParNew收集器:ParNew
      • Parallel Scanvenge收集器:PSYoungGen
      • 老年代和新生代同理,也是和收集器名称相关
    • 76800K->8433K(89600K):GC前该内存区域已使用容量 -> GC后该区域容量(该区域总容量)
      • 如果是新生代,总容量则会显示整个新生代内存的9/10,即eden + from区(默认SurvivorRatio = 8)
      • 如果是老年代,总容量则是全部内存大小,无变化
  • 76800K -> 8449K(294400K)
    • 在显示完区域容量GC的情况之后,会接着显示整个堆内存区域的GC情况:GC前堆内存已使用容量 -> GC堆内存容量(堆内存总容量)
    • 堆内存总容量 = 9/10新生代+老年代 < 初始化的内存大小.
  • 0.0088371 secs]
    • 整个GC所花费的时间,单位是秒
  • [Times:user=0.02 sys=0.01, real=0.01 secs]
    • user:指的是CPU工作在用户态所花费的时间
    • sys:指的是CPU工作在内核态所花费的时间
    • real:指的是在此次GC事件中所花费的总时间
5、Full GC日志解析

-XX:+PrintGCTimeStamps + -XX:+PrintGCDateStamps + -XX:+PrintGCDetails

1
2
3
2020-11-20T17:19:43.794-0800:1.351:[FULL GC (METADATA GC THRESHOLD)[PSYOUNGGEN:10082K -> 0K(89600K)][PAROLDGEN:32K -> 9638K(204800K)] 
10114K -> 9638K(294400K),[METASPACE:20158K -> 20156K(1067008K)], 0.0285388 SECS]
[TIMES: USER=0.11 SYS=0.00, REAL=0.03 SECS]
  • 2020-11-20T17:19:43.794-0800
    • 日志打印时间日期格式如:2013-05-04T21:53:59.234+0800
  • 1.351
    • gc发生时,Java虛拟机启动以来经过的秒数
  • [Full GC (Metadata GC Threshold)
    • 发生了一次垃圾回收,这是一次FULL GC。它不区分新生代GC还是老年代GC
    • 括号里的内容是gc发生的原因,这里的MetadataGC Threshold的原因是Metaspace区不够用了。
      • Full GC (Ergonomics):JVM自适应调整导致的GC
      • Full GC (System):调用了System.gc()方法
  • [PSYoungGen:10082K -> 0K(89600K)]
    • PSYoungGen:表示GC发生的区域,区域名称与使用的GC收集器是密切相关的
      • Serial收集器:Default New Generation显示DefNew
      • ParNew收集器:ParNew
      • Parallel Scanvenge收集器:PSYoungGen
      • 老年代和新生代同理,也是和收集器名称相关
    • 10082K -> 0K(89600K):GC前该内存区域已使用容量 -> GC后该区域容量(该区域总容量)
      • 如果是新生代,总容量则会显示整个新生代内存的9/10,即eden + from区(默认SurvivorRatio = 8)
      • 如果是老年代,总容量则是全部内存大小,无变化
  • [ParOldGen:32K -> 9638K(204800K)]
    • 老年代区域没有发生GC,因为本次GC是metaspace引起的
  • 10114K -> 9638K(294400K)
    • 在显示完区域容量GC的情况之后,会接着显示整个堆内存区域的GC情况:GC前堆内存已使用容量 -> GC堆内存容量(堆内存总容量)
    • 堆内存总容量 = 9/10新生代+老年代 < 初始化的内存大小.
  • [Metaspace:20158K -> 20156K(1067008K)]
    • metaspace GC回收2K空间
  • 0.0285388 secs]
    • 整个GC所花费的时间,单位是秒
  • [Times:user=0.11 sys=0.00, real=0.03 secs]
    • user:指的是CPU工作在用户态所花费的时间
    • sys:指的是CPU工作在内核态所花费的时间
    • real:指的是在此次GC事件中所花费的总时间
6、G1 GC的日志分析

参考博客

参考博客

3、GC日志分析工具

1、GCEasy
  • 基本概述:
    • GCEasy 是一款在线的 GC 日志分析器,可以通过 GC 日志分析进行内存泄露检测、GC 暂停原因分析、JVM 配置建议优化等功能,大多数功能是免费的。(当然有一些服务还是收费的)
  • 下载安装:官网地址

选择需要分析的log文件 -> 点击Analyze -> 可以点击Download将分析结果下载下来进行离线分析

image-20210719024156110

相关分析:

  • 案例1:MetaspaceOOM

    image-20210719025413453

  • 案例2:老年代满了导致堆OOM

    image-20210719025515161

2、GCViewer
  • 基本概述
    • GCViewer是一个免费的、开源的分析小工具,用于可视化查看由SUN/Oracle、IBM、HP和BEA Java虚拟机产生的垃圾收集器的日志。
    • GCViewer 是一款离线的 GC 日志分析器,用于可视化 Java VM 选项 -verbose:gc 和 .NET 生成的数据 -Xloggc:。还可以计算与垃圾回收相关的性能指标(吞吐量、累积的暂停、最长的暂停等)。
    • 当通过更改世代大小或设置初始堆大小来调整特定应用程序的垃圾回收时,此功能非常有用
  • 下载安装:
    • 源码下载
    • 运行版本下载
    • 下载之后只需双击gcviewer-1.3x. jar或运行java -jar gcviewer-1.3x.jar (它需要运行java1.8 vm),即可启动GCViewer (gui)
3、其他工具
  • GChisto
    • 基本概述:GChisto是一款专业分析gc日志的工具,可以通过gc日志来分析:MinorGC、Full GC的次数、频率、持续时间等,通过列表、报表、图表等不同形式来反应gc的情况。
    • 虽然界面略显粗糙,但是功能还是不错的。
    • 官网上没有下载的地方,需要自己从 SVN 上拉下来编译
    • 不过这个工具似乎没怎么维护了,存在不少 bug
  • HPjmeter
    • 工具很强大,但是只能打开由以下参数生成的 GC log,-verbose:gc -Xloggc:gc.log。添加其他参数生成的 gc.log 无法打开
    • HPjmeter 集成了以前的 HPjtune 功能,可以分析在 HP 机器上产生的垃圾回收日志文件

6、OOM常见各种场景及解决方案

1、案例1:堆溢出

2、案例2:元空间溢出

3、案例3:GC overhead limit exceeded

4、案例4:线程溢出

7、性能优化案例

1、性能测试工具:Jmeter

2、案例1:调整堆大小提高服务的吞吐量

3、案例2:调整垃圾回收器提高服务的吞吐量

4、案例3:JVM优化之JIT优化

5、案例4:G1并发执行的线程数对性能的影响

6、案例5:合理配置堆内存

7、特殊问题:新生代与老年代的比例

1、参数设置
2、参数AdaptiveSizePolicy
  • 补充

8、案例6:CPU占用很高排查方案

9、日均百万级订单交易系统如何设置JVM参数

1、现状
2、解决思路
3、参数配置

8、Java代码层及其它层面调优

9、大厂面试题

  • 支付宝:
    • 支付宝三面:JVM性能调优都做了什么?
  • 小米:
    • 有做过JVM内存优化吗?
    • 从SQL、JVM、 架构、数据库四个方面讲讲优化思路
  • 蚂蚁金服:
    • JVM的编译优化
    • JVM性能调优都做了什么
    • JVM诊断调优工具用过哪些?
    • 二面:jvm怎样调优,堆内存、栈空间设置多少合适
    • 三面:JVM相关的分析工具使用过的有哪些?具体的性能调优步骤如何
  • 阿里:
    • 如何进行JVM调优?有哪些方法?
    • 如何理解内存泄漏问题?有哪些情况会导致内存泄漏?如何解决?
  • 字节跳动:
    • 三面: JVM如何调优、参数怎么调?
  • 拼多多:
    • 从SQL、JVM、架构、数据库四个方面讲讲优化思路
  • 京东:
    • JVM诊断调优工具用过哪些?
    • 每秒几十万并发的秒杀系统为什么会频繁发生GC?
    • 日均百万级交易系统如何优化JVM?
    • 线上生产系统00M如何监控及定位与解决?
    • 高并发系统如何基于G1垃圾回收器优化性能?

参考资料:

Java引用类型:强引用,软引用,弱引用,虚引用

浅谈双亲委派机制的缺陷及打破双亲委派机制

谈谈双亲委派模型的第四次破坏——模块化

相关网站:

参数查找